Skip to content

Commit 641e363

Browse files
pepicrftclaude
andcommitted
Handle invalid source control URLs in registry identity lookup
Add validation for source control URLs in `lookupIdentities`: 1. Client-side validation before making HTTP requests: - URL must be parseable - URL must have a valid host - URL must not contain whitespace (indicates malformed URL with concatenated error messages) 2. Server-side validation via HTTP 400 response: - When the server returns 400 Bad Request, throw `RegistryError.invalidSourceControlURL` This handles cases where malformed URLs (e.g., containing git credential error messages like "'URL': failed looking up identity...") are passed to the registry. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5547e32 commit 641e363

File tree

2 files changed

+73
-1
lines changed

2 files changed

+73
-1
lines changed

Sources/PackageRegistry/RegistryClient.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,15 @@ public final class RegistryClient: AsyncCancellable {
10171017
timeout: DispatchTimeInterval? = .none,
10181018
observabilityScope: ObservabilityScope
10191019
) async throws -> Set<PackageIdentity> {
1020+
// Validate the URL before making a request.
1021+
// Valid source control URLs should be parseable and not contain whitespace
1022+
// (whitespace typically indicates a malformed URL with concatenated error messages).
1023+
guard let url = scmURL.url,
1024+
url.host != nil,
1025+
!scmURL.absoluteString.contains(where: \.isWhitespace) else {
1026+
throw RegistryError.invalidSourceControlURL(scmURL)
1027+
}
1028+
10201029
guard let registry = self.configuration.defaultRegistry else {
10211030
throw RegistryError.registryNotConfigured(scope: nil)
10221031
}
@@ -1066,14 +1075,19 @@ public final class RegistryClient: AsyncCancellable {
10661075
)
10671076
observabilityScope.emit(debug: "matched identities for \(scmURL): \(packageIdentities)")
10681077
return Set(packageIdentities.identifiers.map(PackageIdentity.plain))
1078+
case 400:
1079+
// 400 indicates the server rejected the URL as invalid.
1080+
// This can happen when malformed URLs (e.g., containing git credential error messages)
1081+
// are passed to the registry.
1082+
throw RegistryError.invalidSourceControlURL(scmURL)
10691083
case 404:
10701084
// 404 is valid, no identities mapped
10711085
return []
10721086
default:
10731087
throw RegistryError.failedIdentityLookup(
10741088
registry: registry,
10751089
scmURL: scmURL,
1076-
error: self.unexpectedStatusError(response, expectedStatus: [200, 404])
1090+
error: self.unexpectedStatusError(response, expectedStatus: [200, 400, 404])
10771091
)
10781092
}
10791093
}
@@ -1503,6 +1517,7 @@ public enum RegistryError: Error, CustomStringConvertible {
15031517
case registryNotConfigured(scope: PackageIdentity.Scope?)
15041518
case invalidPackageIdentity(PackageIdentity)
15051519
case invalidURL(URL)
1520+
case invalidSourceControlURL(SourceControlURL)
15061521
case invalidResponseStatus(expected: [Int], actual: Int)
15071522
case invalidContentVersion(expected: String, actual: String?)
15081523
case invalidContentType(expected: String, actual: String?)
@@ -1580,6 +1595,8 @@ public enum RegistryError: Error, CustomStringConvertible {
15801595
return "invalid package identifier '\(packageIdentity)'"
15811596
case .invalidURL(let url):
15821597
return "invalid URL '\(url)'"
1598+
case .invalidSourceControlURL(let scmURL):
1599+
return "invalid source control URL '\(scmURL)'"
15831600
case .invalidResponseStatus(let expected, let actual):
15841601
return "invalid registry response status '\(actual)', expected '\(expected)'"
15851602
case .invalidContentVersion(let expected, let actual):

Tests/PackageRegistryTests/RegistryClientTests.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3148,6 +3148,61 @@ fileprivate var availabilityURL = URL("\(registryURL)/availability")
31483148
let identities = try await registryClient.lookupIdentities(scmURL: packageURL)
31493149
#expect([PackageIdentity.plain("mona.LinkedList")] == identities)
31503150
}
3151+
3152+
@Test func invalidSourceControlURL_clientValidation() async throws {
3153+
// Test that malformed URLs with whitespace are rejected before making HTTP requests
3154+
let malformedURL = SourceControlURL("https://github.com/owner/repo.git': failed looking up identity")
3155+
3156+
let httpClient = HTTPClient(implementation: { _, _ in
3157+
Issue.record("Should not make HTTP request for invalid URL")
3158+
throw StringError("unexpected HTTP request")
3159+
})
3160+
var configuration = RegistryConfiguration()
3161+
configuration.defaultRegistry = Registry(url: registryURL, supportsAvailability: false)
3162+
3163+
let registryClient = makeRegistryClient(configuration: configuration, httpClient: httpClient)
3164+
await #expect {
3165+
try await registryClient.lookupIdentities(scmURL: malformedURL)
3166+
} throws: { error in
3167+
if case RegistryError.invalidSourceControlURL(malformedURL) = error {
3168+
return true
3169+
}
3170+
return false
3171+
}
3172+
}
3173+
3174+
@Test func invalidSourceControlURL_serverValidation() async throws {
3175+
// Test that when the server returns 400 Bad Request, the client throws invalidSourceControlURL
3176+
let scmURL = packageURL
3177+
3178+
let handler: HTTPClient.Implementation = { request, _ in
3179+
// Server returns 400 Bad Request for invalid URLs
3180+
let data = #"{"message": "Invalid repository URL"}"#.data(using: .utf8)!
3181+
return .init(
3182+
statusCode: 400,
3183+
headers: .init([
3184+
.init(name: "Content-Length", value: "\(data.count)"),
3185+
.init(name: "Content-Type", value: "application/json"),
3186+
.init(name: "Content-Version", value: "1"),
3187+
]),
3188+
body: data
3189+
)
3190+
}
3191+
3192+
let httpClient = HTTPClient(implementation: handler)
3193+
var configuration = RegistryConfiguration()
3194+
configuration.defaultRegistry = Registry(url: registryURL, supportsAvailability: false)
3195+
3196+
let registryClient = makeRegistryClient(configuration: configuration, httpClient: httpClient)
3197+
await #expect {
3198+
try await registryClient.lookupIdentities(scmURL: scmURL)
3199+
} throws: { error in
3200+
if case RegistryError.invalidSourceControlURL(scmURL) = error {
3201+
return true
3202+
}
3203+
return false
3204+
}
3205+
}
31513206
}
31523207

31533208
@Suite("Login") struct Login {

0 commit comments

Comments
 (0)