Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Sources/Basics/SourceControlURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,32 @@ public struct SourceControlURL: Codable, Equatable, Hashable, Sendable {
public var url: URL? {
return URL(string: self.urlString)
}

/// Whether this URL appears to be a valid source control URL.
///
/// Valid source control URLs must:
/// - Be parseable as a URL (or match SSH-style git URL format)
/// - Have a non-empty host
/// - Not contain whitespace (which would indicate a malformed URL,
/// e.g., one concatenated with an error message)
public var isValid: Bool {
// URLs with whitespace are invalid (typically indicates concatenated error messages)
guard !self.urlString.contains(where: \.isWhitespace) else {
return false
}

// Check for standard URL format (http://, https://, ssh://, etc.)
if let url = self.url,
let host = url.host,
!host.isEmpty {
return true
}

// Check for SSH-style git URLs: git@host:path or user@host:path
// These don't parse as standard URLs but are valid git URLs
let sshPattern = #/^[\w.-]+@[\w.-]+:.+/#
return self.urlString.contains(sshPattern)
}
}

extension SourceControlURL: CustomStringConvertible {
Expand Down
10 changes: 9 additions & 1 deletion Sources/PackageRegistry/RegistryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1066,14 +1066,19 @@ public final class RegistryClient: AsyncCancellable {
)
observabilityScope.emit(debug: "matched identities for \(scmURL): \(packageIdentities)")
return Set(packageIdentities.identifiers.map(PackageIdentity.plain))
case 400:
// 400 indicates the server rejected the URL as invalid.
// This can happen when malformed URLs (e.g., containing git credential error messages)
// are passed to the registry.
throw RegistryError.invalidSourceControlURL(scmURL)
case 404:
// 404 is valid, no identities mapped
return []
default:
throw RegistryError.failedIdentityLookup(
registry: registry,
scmURL: scmURL,
error: self.unexpectedStatusError(response, expectedStatus: [200, 404])
error: self.unexpectedStatusError(response, expectedStatus: [200, 400, 404])
)
}
}
Expand Down Expand Up @@ -1503,6 +1508,7 @@ public enum RegistryError: Error, CustomStringConvertible {
case registryNotConfigured(scope: PackageIdentity.Scope?)
case invalidPackageIdentity(PackageIdentity)
case invalidURL(URL)
case invalidSourceControlURL(SourceControlURL)
case invalidResponseStatus(expected: [Int], actual: Int)
case invalidContentVersion(expected: String, actual: String?)
case invalidContentType(expected: String, actual: String?)
Expand Down Expand Up @@ -1580,6 +1586,8 @@ public enum RegistryError: Error, CustomStringConvertible {
return "invalid package identifier '\(packageIdentity)'"
case .invalidURL(let url):
return "invalid URL '\(url)'"
case .invalidSourceControlURL(let scmURL):
return "invalid source control URL '\(scmURL)'"
case .invalidResponseStatus(let expected, let actual):
return "invalid registry response status '\(actual)', expected '\(expected)'"
case .invalidContentVersion(let expected, let actual):
Expand Down
6 changes: 6 additions & 0 deletions Sources/Workspace/Workspace+Registry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import class Basics.ObservabilityScope
import struct Basics.SourceControlURL
import class Basics.ThreadSafeKeyValueStore
import class PackageGraph.ResolvedPackagesStore
import enum PackageRegistry.RegistryError
import protocol PackageLoading.ManifestLoaderProtocol
import protocol PackageModel.DependencyMapper
import protocol PackageModel.IdentityResolver
Expand Down Expand Up @@ -332,6 +333,11 @@ extension Workspace {
url: SourceControlURL,
observabilityScope: ObservabilityScope
) async throws -> PackageIdentity? {
// Validate URL before attempting registry lookup
guard url.isValid else {
throw RegistryError.invalidSourceControlURL(url)
}

if let cached = self.identityLookupCache[url], cached.expirationTime > .now() {
switch cached.result {
case .success(let identity):
Expand Down
44 changes: 44 additions & 0 deletions Tests/BasicsTests/SourceControlURLTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import Basics
import Testing

@Suite("SourceControlURL")
struct SourceControlURLTests {
@Test func validURLs() {
#expect(SourceControlURL("https://github.com/owner/repo").isValid)
#expect(SourceControlURL("https://github.com/owner/repo.git").isValid)
#expect(SourceControlURL("git@github.com:owner/repo.git").isValid)
#expect(SourceControlURL("ssh://git@github.com/owner/repo.git").isValid)
#expect(SourceControlURL("http://example.com/path/to/repo").isValid)
}

@Test func invalidURLs_withWhitespace() {
// URLs containing whitespace are invalid (typically indicates concatenated error messages)
#expect(!SourceControlURL("https://github.com/owner/repo.git': failed looking up identity").isValid)
#expect(!SourceControlURL("https://github.com/owner/repo error message").isValid)
#expect(!SourceControlURL("https://github.com/owner/repo\there").isValid)
#expect(!SourceControlURL("https://github.com/owner/repo\nhere").isValid)
}

@Test func invalidURLs_unparseable() {
// URLs that can't be parsed
#expect(!SourceControlURL("not a url").isValid)
#expect(!SourceControlURL("").isValid)
}

@Test func invalidURLs_noHost() {
// URLs without a host
#expect(!SourceControlURL("file:///path/to/repo").isValid)
}
}
33 changes: 33 additions & 0 deletions Tests/PackageRegistryTests/RegistryClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3148,6 +3148,39 @@ fileprivate var availabilityURL = URL("\(registryURL)/availability")
let identities = try await registryClient.lookupIdentities(scmURL: packageURL)
#expect([PackageIdentity.plain("mona.LinkedList")] == identities)
}

@Test func serverReturns400_throwsInvalidSourceControlURL() async throws {
// Test that when the server returns 400 Bad Request, the client throws invalidSourceControlURL
let scmURL = packageURL

let handler: HTTPClient.Implementation = { request, _ in
// Server returns 400 Bad Request for invalid URLs
let data = #"{"message": "Invalid repository URL"}"#.data(using: .utf8)!
return .init(
statusCode: 400,
headers: .init([
.init(name: "Content-Length", value: "\(data.count)"),
.init(name: "Content-Type", value: "application/json"),
.init(name: "Content-Version", value: "1"),
]),
body: data
)
}

let httpClient = HTTPClient(implementation: handler)
var configuration = RegistryConfiguration()
configuration.defaultRegistry = Registry(url: registryURL, supportsAvailability: false)

let registryClient = makeRegistryClient(configuration: configuration, httpClient: httpClient)
await #expect {
try await registryClient.lookupIdentities(scmURL: scmURL)
} throws: { error in
if case RegistryError.invalidSourceControlURL(scmURL) = error {
return true
}
return false
}
}
}

@Suite("Login") struct Login {
Expand Down