Skip to content

Commit a1a3f98

Browse files
pepicrftclaude
andcommitted
Handle invalid source control URLs in registry identity lookup
Add validation for source control URLs at multiple levels: 1. Add `isValid` property to `SourceControlURL` that checks: - URL doesn't contain whitespace (indicates malformed URL) - URL is parseable as a standard URL with a host, OR - URL matches SSH-style git format (user@host:path) 2. Validate URLs early in `mapRegistryIdentity` before making registry requests 3. Handle HTTP 400 responses from the registry server by throwing `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 <[email protected]>
1 parent 5547e32 commit a1a3f98

File tree

5 files changed

+118
-1
lines changed

5 files changed

+118
-1
lines changed

Sources/Basics/SourceControlURL.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,32 @@ public struct SourceControlURL: Codable, Equatable, Hashable, Sendable {
3838
public var url: URL? {
3939
return URL(string: self.urlString)
4040
}
41+
42+
/// Whether this URL appears to be a valid source control URL.
43+
///
44+
/// Valid source control URLs must:
45+
/// - Be parseable as a URL (or match SSH-style git URL format)
46+
/// - Have a non-empty host
47+
/// - Not contain whitespace (which would indicate a malformed URL,
48+
/// e.g., one concatenated with an error message)
49+
public var isValid: Bool {
50+
// URLs with whitespace are invalid (typically indicates concatenated error messages)
51+
guard !self.urlString.contains(where: \.isWhitespace) else {
52+
return false
53+
}
54+
55+
// Check for standard URL format (http://, https://, ssh://, etc.)
56+
if let url = self.url,
57+
let host = url.host,
58+
!host.isEmpty {
59+
return true
60+
}
61+
62+
// Check for SSH-style git URLs: git@host:path or user@host:path
63+
// These don't parse as standard URLs but are valid git URLs
64+
let sshPattern = #/^[\w.-]+@[\w.-]+:.+/#
65+
return self.urlString.contains(sshPattern)
66+
}
4167
}
4268

4369
extension SourceControlURL: CustomStringConvertible {

Sources/PackageRegistry/RegistryClient.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1066,14 +1066,19 @@ public final class RegistryClient: AsyncCancellable {
10661066
)
10671067
observabilityScope.emit(debug: "matched identities for \(scmURL): \(packageIdentities)")
10681068
return Set(packageIdentities.identifiers.map(PackageIdentity.plain))
1069+
case 400:
1070+
// 400 indicates the server rejected the URL as invalid.
1071+
// This can happen when malformed URLs (e.g., containing git credential error messages)
1072+
// are passed to the registry.
1073+
throw RegistryError.invalidSourceControlURL(scmURL)
10691074
case 404:
10701075
// 404 is valid, no identities mapped
10711076
return []
10721077
default:
10731078
throw RegistryError.failedIdentityLookup(
10741079
registry: registry,
10751080
scmURL: scmURL,
1076-
error: self.unexpectedStatusError(response, expectedStatus: [200, 404])
1081+
error: self.unexpectedStatusError(response, expectedStatus: [200, 400, 404])
10771082
)
10781083
}
10791084
}
@@ -1503,6 +1508,7 @@ public enum RegistryError: Error, CustomStringConvertible {
15031508
case registryNotConfigured(scope: PackageIdentity.Scope?)
15041509
case invalidPackageIdentity(PackageIdentity)
15051510
case invalidURL(URL)
1511+
case invalidSourceControlURL(SourceControlURL)
15061512
case invalidResponseStatus(expected: [Int], actual: Int)
15071513
case invalidContentVersion(expected: String, actual: String?)
15081514
case invalidContentType(expected: String, actual: String?)
@@ -1580,6 +1586,8 @@ public enum RegistryError: Error, CustomStringConvertible {
15801586
return "invalid package identifier '\(packageIdentity)'"
15811587
case .invalidURL(let url):
15821588
return "invalid URL '\(url)'"
1589+
case .invalidSourceControlURL(let scmURL):
1590+
return "invalid source control URL '\(scmURL)'"
15831591
case .invalidResponseStatus(let expected, let actual):
15841592
return "invalid registry response status '\(actual)', expected '\(expected)'"
15851593
case .invalidContentVersion(let expected, let actual):

Sources/Workspace/Workspace+Registry.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import class Basics.ObservabilityScope
1919
import struct Basics.SourceControlURL
2020
import class Basics.ThreadSafeKeyValueStore
2121
import class PackageGraph.ResolvedPackagesStore
22+
import enum PackageRegistry.RegistryError
2223
import protocol PackageLoading.ManifestLoaderProtocol
2324
import protocol PackageModel.DependencyMapper
2425
import protocol PackageModel.IdentityResolver
@@ -332,6 +333,11 @@ extension Workspace {
332333
url: SourceControlURL,
333334
observabilityScope: ObservabilityScope
334335
) async throws -> PackageIdentity? {
336+
// Validate URL before attempting registry lookup
337+
guard url.isValid else {
338+
throw RegistryError.invalidSourceControlURL(url)
339+
}
340+
335341
if let cached = self.identityLookupCache[url], cached.expirationTime > .now() {
336342
switch cached.result {
337343
case .success(let identity):
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 Swift open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Basics
14+
import Testing
15+
16+
@Suite("SourceControlURL")
17+
struct SourceControlURLTests {
18+
@Test func validURLs() {
19+
#expect(SourceControlURL("https://github.com/owner/repo").isValid)
20+
#expect(SourceControlURL("https://github.com/owner/repo.git").isValid)
21+
#expect(SourceControlURL("[email protected]:owner/repo.git").isValid)
22+
#expect(SourceControlURL("ssh://[email protected]/owner/repo.git").isValid)
23+
#expect(SourceControlURL("http://example.com/path/to/repo").isValid)
24+
}
25+
26+
@Test func invalidURLs_withWhitespace() {
27+
// URLs containing whitespace are invalid (typically indicates concatenated error messages)
28+
#expect(!SourceControlURL("https://github.com/owner/repo.git': failed looking up identity").isValid)
29+
#expect(!SourceControlURL("https://github.com/owner/repo error message").isValid)
30+
#expect(!SourceControlURL("https://github.com/owner/repo\there").isValid)
31+
#expect(!SourceControlURL("https://github.com/owner/repo\nhere").isValid)
32+
}
33+
34+
@Test func invalidURLs_unparseable() {
35+
// URLs that can't be parsed
36+
#expect(!SourceControlURL("not a url").isValid)
37+
#expect(!SourceControlURL("").isValid)
38+
}
39+
40+
@Test func invalidURLs_noHost() {
41+
// URLs without a host
42+
#expect(!SourceControlURL("file:///path/to/repo").isValid)
43+
}
44+
}

Tests/PackageRegistryTests/RegistryClientTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3148,6 +3148,39 @@ 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 serverReturns400_throwsInvalidSourceControlURL() async throws {
3153+
// Test that when the server returns 400 Bad Request, the client throws invalidSourceControlURL
3154+
let scmURL = packageURL
3155+
3156+
let handler: HTTPClient.Implementation = { request, _ in
3157+
// Server returns 400 Bad Request for invalid URLs
3158+
let data = #"{"message": "Invalid repository URL"}"#.data(using: .utf8)!
3159+
return .init(
3160+
statusCode: 400,
3161+
headers: .init([
3162+
.init(name: "Content-Length", value: "\(data.count)"),
3163+
.init(name: "Content-Type", value: "application/json"),
3164+
.init(name: "Content-Version", value: "1"),
3165+
]),
3166+
body: data
3167+
)
3168+
}
3169+
3170+
let httpClient = HTTPClient(implementation: handler)
3171+
var configuration = RegistryConfiguration()
3172+
configuration.defaultRegistry = Registry(url: registryURL, supportsAvailability: false)
3173+
3174+
let registryClient = makeRegistryClient(configuration: configuration, httpClient: httpClient)
3175+
await #expect {
3176+
try await registryClient.lookupIdentities(scmURL: scmURL)
3177+
} throws: { error in
3178+
if case RegistryError.invalidSourceControlURL(scmURL) = error {
3179+
return true
3180+
}
3181+
return false
3182+
}
3183+
}
31513184
}
31523185

31533186
@Suite("Login") struct Login {

0 commit comments

Comments
 (0)