Skip to content

Commit bf5e256

Browse files
authored
ContainerRegistry: Reject invalid repository names (#138)
Motivation ---------- `ImageReference` does not check for illegal characters in parsed image references. This means that `containertool` will send illegal image names to the registry. The registry will reject them, but the error message might not explain why, so a generic error message will be printed. Runtimes reject illegal image references immediately, without sending them to the registry. Modifications ------------- * Introduce a `Repository` wrapper type * Check validity when constructing the `Repository` * Change the low-level API functions to accept `Repository` instead of `String`. Result ------ It is impossible to create a `Repository` object containing a malformed name, because the constructor checks the string value. It is impossible to send a malformed name to the registry because the API wrappers only accept `Repository` objects. Fixes #135 Test Plan --------- Existing tests continue to pass. New tests exercise additional checks which were previously missing.
1 parent 3030089 commit bf5e256

File tree

11 files changed

+247
-77
lines changed

11 files changed

+247
-77
lines changed

Sources/ContainerRegistry/AuthHandler.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ public struct AuthHandler {
158158

159159
public func auth(
160160
registry: URL,
161-
repository: String,
161+
repository: ImageReference.Repository,
162162
actions: [String],
163163
withScheme scheme: AuthChallenge,
164164
usingClient client: HTTPClient

Sources/ContainerRegistry/Blobs.swift

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,7 @@ public func digest<D: DataProtocol>(of data: D) -> String {
2828

2929
extension RegistryClient {
3030
// Internal helper method to initiate a blob upload in 'two shot' mode
31-
func startBlobUploadSession(repository: String) async throws -> URL {
32-
precondition(repository.count > 0, "repository must not be an empty string")
33-
31+
func startBlobUploadSession(repository: ImageReference.Repository) async throws -> URL {
3432
// Upload in "two shot" mode.
3533
// See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put
3634
// - POST to obtain a session ID.
@@ -67,8 +65,7 @@ extension RegistryClient {
6765
extension HTTPField.Name { static let dockerContentDigest = Self("Docker-Content-Digest")! }
6866

6967
public extension RegistryClient {
70-
func blobExists(repository: String, digest: String) async throws -> Bool {
71-
precondition(repository.count > 0, "repository must not be an empty string")
68+
func blobExists(repository: ImageReference.Repository, digest: String) async throws -> Bool {
7269
precondition(digest.count > 0)
7370

7471
do {
@@ -87,8 +84,7 @@ public extension RegistryClient {
8784
/// - digest: Digest of the blob.
8885
/// - Returns: The downloaded data.
8986
/// - Throws: If the blob download fails.
90-
func getBlob(repository: String, digest: String) async throws -> Data {
91-
precondition(repository.count > 0, "repository must not be an empty string")
87+
func getBlob(repository: ImageReference.Repository, digest: String) async throws -> Data {
9288
precondition(digest.count > 0, "digest must not be an empty string")
9389

9490
return try await executeRequestThrowing(
@@ -110,8 +106,7 @@ public extension RegistryClient {
110106
/// in the registry as plain blobs with MIME type "application/octet-stream".
111107
/// This function attempts to decode the received data without reference
112108
/// to the MIME type.
113-
func getBlob<Response: Decodable>(repository: String, digest: String) async throws -> Response {
114-
precondition(repository.count > 0, "repository must not be an empty string")
109+
func getBlob<Response: Decodable>(repository: ImageReference.Repository, digest: String) async throws -> Response {
115110
precondition(digest.count > 0, "digest must not be an empty string")
116111

117112
return try await executeRequestThrowing(
@@ -132,11 +127,10 @@ public extension RegistryClient {
132127
/// - Returns: An ContentDescriptor object representing the
133128
/// uploaded blob.
134129
/// - Throws: If the blob cannot be encoded or the upload fails.
135-
func putBlob(repository: String, mediaType: String = "application/octet-stream", data: Data) async throws
130+
func putBlob(repository: ImageReference.Repository, mediaType: String = "application/octet-stream", data: Data)
131+
async throws
136132
-> ContentDescriptor
137133
{
138-
precondition(repository.count > 0, "repository must not be an empty string")
139-
140134
// Ask the server to open a session and tell us where to upload our data
141135
let location = try await startBlobUploadSession(repository: repository)
142136

@@ -179,7 +173,11 @@ public extension RegistryClient {
179173
/// Some JSON objects, such as ImageConfiguration, are stored
180174
/// in the registry as plain blobs with MIME type "application/octet-stream".
181175
/// This function encodes the data parameter and uploads it as a generic blob.
182-
func putBlob<Body: Encodable>(repository: String, mediaType: String = "application/octet-stream", data: Body)
176+
func putBlob<Body: Encodable>(
177+
repository: ImageReference.Repository,
178+
mediaType: String = "application/octet-stream",
179+
data: Body
180+
)
183181
async throws -> ContentDescriptor
184182
{
185183
let encoded = try encoder.encode(data)

Sources/ContainerRegistry/ImageReference.swift

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@
1414

1515
import RegexBuilder
1616

17-
enum ReferenceError: Error { case unexpected(String) }
18-
1917
// https://github.com/distribution/distribution/blob/v2.7.1/reference/reference.go
2018
// Split the image reference into a registry and a name part.
2119
func splitReference(_ reference: String) throws -> (String?, String) {
2220
let splits = reference.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
23-
if splits.count == 0 { throw ReferenceError.unexpected("unexpected error") }
21+
if splits.count == 0 { throw ImageReference.ValidationError.unexpected("unexpected error") }
2422

2523
if splits.count == 1 { return (nil, reference) }
2624

@@ -39,7 +37,7 @@ func splitName(_ name: String) throws -> (String, String) {
3937
if digestSplit.count == 2 { return (String(digestSplit[0]), String(digestSplit[1])) }
4038

4139
let tagSplit = name.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
42-
if tagSplit.count == 0 { throw ReferenceError.unexpected("unexpected error") }
40+
if tagSplit.count == 0 { throw ImageReference.ValidationError.unexpected("unexpected error") }
4341

4442
if tagSplit.count == 1 { return (name, "latest") }
4543

@@ -52,10 +50,14 @@ public struct ImageReference: Sendable, Equatable, CustomStringConvertible, Cust
5250
/// The registry which contains this image
5351
public var registry: String
5452
/// The repository which contains this image
55-
public var repository: String
53+
public var repository: Repository
5654
/// The tag identifying the image.
5755
public var reference: String
5856

57+
public enum ValidationError: Error {
58+
case unexpected(String)
59+
}
60+
5961
/// Creates an ImageReference from an image reference string.
6062
/// - Parameters:
6163
/// - reference: The reference to parse.
@@ -72,19 +74,20 @@ public struct ImageReference: Sendable, Equatable, CustomStringConvertible, Cust
7274
// moby/moby assumes that these names refer to images in `library`: `library/swift` or `library/swift:slim`.
7375
// This special case only applies when using Docker Hub, so `example.com/swift` is not expanded `example.com/library/swift`
7476
if self.registry == "index.docker.io" && !repository.contains("/") {
75-
self.repository = "library/\(repository)"
77+
self.repository = try Repository("library/\(repository)")
7678
} else {
77-
self.repository = repository
79+
self.repository = try Repository(repository)
7880
}
7981
self.reference = reference
8082
}
8183

8284
/// Creates an ImageReference from separate registry, repository and reference strings.
85+
/// Used only in tests.
8386
/// - Parameters:
8487
/// - registry: The registry which stores the image data.
8588
/// - repository: The repository within the registry which holds the image.
8689
/// - reference: The tag identifying the image.
87-
public init(registry: String, repository: String, reference: String) {
90+
init(registry: String, repository: Repository, reference: String) {
8891
self.registry = registry
8992
self.repository = repository
9093
self.reference = reference
@@ -104,3 +107,45 @@ public struct ImageReference: Sendable, Equatable, CustomStringConvertible, Cust
104107
"ImageReference(registry: \(registry), repository: \(repository), reference: \(reference))"
105108
}
106109
}
110+
111+
extension ImageReference {
112+
/// Repository refers a repository (image namespace) on a container registry
113+
public struct Repository: Sendable, Equatable, CustomStringConvertible, CustomDebugStringConvertible {
114+
var value: String
115+
116+
public enum ValidationError: Error, Equatable {
117+
case emptyString
118+
case containsUppercaseLetters(String)
119+
case invalidReferenceFormat(String)
120+
}
121+
122+
public init(_ rawValue: String) throws {
123+
// Reference handling in github.com/distribution reports empty and uppercase as specific errors.
124+
// All other errors caused are reported as generic format errors.
125+
guard rawValue.count > 0 else {
126+
throw ValidationError.emptyString
127+
}
128+
129+
if (rawValue.contains { $0.isUppercase }) {
130+
throw ValidationError.containsUppercaseLetters(rawValue)
131+
}
132+
133+
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
134+
let regex = /[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*/
135+
if try regex.wholeMatch(in: rawValue) == nil {
136+
throw ValidationError.invalidReferenceFormat(rawValue)
137+
}
138+
139+
value = rawValue
140+
}
141+
142+
public var description: String {
143+
value
144+
}
145+
146+
/// Printable description of an ImageReference in a form suitable for debugging.
147+
public var debugDescription: String {
148+
"Repository(\(value))"
149+
}
150+
}
151+
}

Sources/ContainerRegistry/Manifests.swift

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
public extension RegistryClient {
16-
func putManifest(repository: String, reference: String, manifest: ImageManifest) async throws -> String {
16+
func putManifest(repository: ImageReference.Repository, reference: String, manifest: ImageManifest) async throws
17+
-> String
18+
{
1719
// See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests
18-
precondition(repository.count > 0, "repository must not be an empty string")
19-
precondition(reference.count > 0, "reference must not be an empty string")
20+
precondition("\(reference)".count > 0, "reference must not be an empty string")
2021

2122
let httpResponse = try await executeRequestThrowing(
2223
// All blob uploads have Content-Type: application/octet-stream on the wire, even if mediatype is different
@@ -41,9 +42,8 @@ public extension RegistryClient {
4142
.absoluteString
4243
}
4344

44-
func getManifest(repository: String, reference: String) async throws -> ImageManifest {
45+
func getManifest(repository: ImageReference.Repository, reference: String) async throws -> ImageManifest {
4546
// See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
46-
precondition(repository.count > 0, "repository must not be an empty string")
4747
precondition(reference.count > 0, "reference must not be an empty string")
4848

4949
return try await executeRequestThrowing(
@@ -60,8 +60,7 @@ public extension RegistryClient {
6060
.data
6161
}
6262

63-
func getIndex(repository: String, reference: String) async throws -> ImageIndex {
64-
precondition(repository.count > 0, "repository must not be an empty string")
63+
func getIndex(repository: ImageReference.Repository, reference: String) async throws -> ImageIndex {
6564
precondition(reference.count > 0, "reference must not be an empty string")
6665

6766
return try await executeRequestThrowing(

Sources/ContainerRegistry/RegistryClient.swift

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ extension URL {
127127
/// - repository: The name of the repository. May include path separators.
128128
/// - endpoint: The distribution endpoint e.g. "tags/list"
129129
/// - Returns: A fully-qualified URL for the endpoint.
130-
func distributionEndpoint(forRepository repository: String, andEndpoint endpoint: String) -> URL {
130+
func distributionEndpoint(forRepository repository: ImageReference.Repository, andEndpoint endpoint: String) -> URL
131+
{
131132
self.appendingPathComponent("/v2/\(repository)/\(endpoint)")
132133
}
133134
}
@@ -141,7 +142,7 @@ extension RegistryClient {
141142
}
142143

143144
var method: HTTPRequest.Method // HTTP method
144-
var repository: String // Repository path on the registry
145+
var repository: ImageReference.Repository // Repository path on the registry
145146
var destination: Destination // Destination of the operation: can be a subpath or remote URL
146147
var actions: [String] // Actions required by this operation
147148
var accepting: [String] = [] // Acceptable response types
@@ -156,7 +157,7 @@ extension RegistryClient {
156157

157158
// Convenience constructors
158159
static func get(
159-
_ repository: String,
160+
_ repository: ImageReference.Repository,
160161
path: String,
161162
actions: [String]? = nil,
162163
accepting: [String] = [],
@@ -173,7 +174,7 @@ extension RegistryClient {
173174
}
174175

175176
static func get(
176-
_ repository: String,
177+
_ repository: ImageReference.Repository,
177178
url: URL,
178179
actions: [String]? = nil,
179180
accepting: [String] = [],
@@ -190,7 +191,7 @@ extension RegistryClient {
190191
}
191192

192193
static func head(
193-
_ repository: String,
194+
_ repository: ImageReference.Repository,
194195
path: String,
195196
actions: [String]? = nil,
196197
accepting: [String] = [],
@@ -208,7 +209,7 @@ extension RegistryClient {
208209

209210
/// This handles the 'put' case where the registry gives us a location URL which we must not alter, aside from adding the digest to it
210211
static func put(
211-
_ repository: String,
212+
_ repository: ImageReference.Repository,
212213
url: URL,
213214
actions: [String]? = nil,
214215
accepting: [String] = [],
@@ -225,7 +226,7 @@ extension RegistryClient {
225226
}
226227

227228
static func put(
228-
_ repository: String,
229+
_ repository: ImageReference.Repository,
229230
path: String,
230231
actions: [String]? = nil,
231232
accepting: [String] = [],
@@ -242,7 +243,7 @@ extension RegistryClient {
242243
}
243244

244245
static func post(
245-
_ repository: String,
246+
_ repository: ImageReference.Repository,
246247
path: String,
247248
actions: [String]? = nil,
248249
accepting: [String] = [],

Sources/ContainerRegistry/Tags.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
public extension RegistryClient {
16-
func getTags(repository: String) async throws -> Tags {
16+
func getTags(repository: ImageReference.Repository) async throws -> Tags {
1717
// See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-tags
18-
precondition(repository.count > 0, "repository must not be an empty string")
19-
20-
return try await executeRequestThrowing(.get(repository, path: "tags/list"), decodingErrors: [.notFound]).data
18+
try await executeRequestThrowing(.get(repository, path: "tags/list"), decodingErrors: [.notFound]).data
2119
}
2220
}

Sources/containertool/Extensions/Errors+CustomStringConvertible.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,17 @@ extension ContainerRegistry.DistributionErrors: Swift.CustomStringConvertible {
4646
/// A human-readable string describing a collection of unhandled distribution protocol errors
4747
public var description: String { errors.map { $0.description }.joined(separator: "\n") }
4848
}
49+
50+
extension ContainerRegistry.ImageReference.Repository.ValidationError: Swift.CustomStringConvertible {
51+
/// A human-readable string describing an image reference validation error
52+
public var description: String {
53+
switch self {
54+
case .emptyString:
55+
return "Invalid reference format: repository name cannot be empty"
56+
case .containsUppercaseLetters(let rawValue):
57+
return "Invalid reference format: repository name (\(rawValue)) must be lowercase"
58+
case .invalidReferenceFormat(let rawValue):
59+
return "Invalid reference format: repository name (\(rawValue)) contains invalid characters"
60+
}
61+
}
62+
}

Sources/containertool/Extensions/RegistryClient+CopyBlobs.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ extension RegistryClient {
2424
/// - Throws: If the copy cannot be completed.
2525
func copyBlob(
2626
digest: String,
27-
fromRepository sourceRepository: String,
27+
fromRepository sourceRepository: ImageReference.Repository,
2828
toClient destClient: RegistryClient,
29-
toRepository destRepository: String
29+
toRepository destRepository: ImageReference.Repository
3030
) async throws {
3131
if try await destClient.blobExists(repository: destRepository, digest: digest) {
3232
log("Layer \(digest): already exists")

Sources/containertool/Extensions/RegistryClient+Layers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ extension RegistryClient {
4545
// A layer is a tarball, optionally compressed using gzip or zstd
4646
// See https://github.com/opencontainers/image-spec/blob/main/media-types.md
4747
func uploadLayer(
48-
repository: String,
48+
repository: ImageReference.Repository,
4949
contents: [UInt8],
5050
mediaType: String = "application/vnd.oci.image.layer.v1.tar+gzip"
5151
) async throws -> ImageLayer {

0 commit comments

Comments
 (0)