diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 5167181e..6bb73c70 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -38,3 +38,32 @@ jobs: release-builds: name: Release builds uses: apple/swift-nio/.github/workflows/release_builds.yml@main + + construct-linkage-test-matrix: + name: Construct linkage matrix + runs-on: ubuntu-latest + outputs: + linkage-test-matrix: '${{ steps.generate-matrix.outputs.linkage-test-matrix }}' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + - id: generate-matrix + run: echo "linkage-test-matrix=$(curl -s https://raw.githubusercontent.com/apple/swift-nio/main/scripts/generate_matrix.sh | bash)" >> "$GITHUB_OUTPUT" + env: + MATRIX_LINUX_SETUP_COMMAND: apt-get update -y && apt-get install -yq jq && git config --global --add safe.directory /async-http-client + MATRIX_LINUX_COMMAND: ./scripts/run-linkage-test.sh + MATRIX_LINUX_5_10_ENABLED: false + MATRIX_LINUX_6_0_ENABLED: false + MATRIX_LINUX_6_1_ENABLED: false + MATRIX_LINUX_NIGHTLY_NEXT_ENABLED: false + MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false + + linkage-test: + name: Linkage test + needs: construct-linkage-test-matrix + uses: apple/swift-nio/.github/workflows/swift_test_matrix.yml@main + with: + name: "Linkage test" + matrix_string: '${{ needs.construct-linkage-test-matrix.outputs.linkage-test-matrix }}' diff --git a/.github/workflows/pull_request_label.yml b/.github/workflows/pull_request_label.yml index d2da2f1a..ed8754bd 100644 --- a/.github/workflows/pull_request_label.yml +++ b/.github/workflows/pull_request_label.yml @@ -14,7 +14,7 @@ jobs: timeout-minutes: 1 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: persist-credentials: false - name: Check for Semantic Version label diff --git a/Package.swift b/Package.swift index 7ba3334d..d76c1715 100644 --- a/Package.swift +++ b/Package.swift @@ -56,7 +56,8 @@ let package = Package( .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"), .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.3.0"), - .package(url: "https://github.com/apple/swift-configuration.git", from: "1.0.0"), + // Disable all traits to prevent linking Foundation + .package(url: "https://github.com/apple/swift-configuration.git", from: "1.0.0", traits: []), .package(url: "https://github.com/apple/swift-service-context.git", from: "1.1.0"), .package( url: "https://github.com/apple/swift-http-api-proposal.git", @@ -84,7 +85,11 @@ let package = Package( .product(name: "NIOSSL", package: "swift-nio-ssl"), .product(name: "NIOHTTPCompression", package: "swift-nio-extras"), .product(name: "NIOSOCKS", package: "swift-nio-extras"), - .product(name: "NIOTransportServices", package: "swift-nio-transport-services"), + .product( + name: "NIOTransportServices", + package: "swift-nio-transport-services", + condition: .when(platforms: [.macOS, .iOS, .tvOS, .watchOS, .macCatalyst, .visionOS]) + ), .product(name: "Atomics", package: "swift-atomics"), .product(name: "Algorithms", package: "swift-algorithms"), .product(name: "Configuration", package: "swift-configuration"), diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index b77cb952..a1047fa8 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -17,7 +17,11 @@ import NIOCore import NIOHTTP1 import Tracing +#if canImport(FoundationEssentials) +import struct FoundationEssentials.URL +#else import struct Foundation.URL +#endif @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension HTTPClient { @@ -96,6 +100,7 @@ extension HTTPClient { try HTTPClientRequest.Prepared( currentRequest, dnsOverride: configuration.dnsOverride, + localAddress: configuration.localAddress, tracing: self.configuration.tracing ) let response = try await { diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index edc0de61..c4559e7b 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -18,7 +18,11 @@ import NIOHTTP1 import NIOSSL import ServiceContextModule +#if canImport(FoundationEssentials) +import struct FoundationEssentials.URL +#else import struct Foundation.URL +#endif #if canImport(HTTPAPIs) import HTTPAPIs @@ -58,6 +62,7 @@ extension HTTPClientRequest.Prepared { init( _ request: HTTPClientRequest, dnsOverride: [String: String] = [:], + localAddress: String? = nil, tracing: HTTPClient.TracingConfiguration? = nil ) throws { guard !request.url.isEmpty, let url = URL(string: request.url) else { @@ -81,7 +86,12 @@ extension HTTPClientRequest.Prepared { self.init( url: url, - poolKey: .init(url: deconstructedURL, tlsConfiguration: request.tlsConfiguration, dnsOverride: dnsOverride), + poolKey: .init( + url: deconstructedURL, + tlsConfiguration: request.tlsConfiguration, + dnsOverride: dnsOverride, + localAddress: request.localAddress ?? localAddress + ), requestFramingMetadata: metadata, head: .init( version: .http1_1, @@ -156,6 +166,7 @@ extension HTTPClientRequest { newRequest.method = method newRequest.headers = headers newRequest.body = body + newRequest.localAddress = self.localAddress return newRequest } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+auth.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+auth.swift index 106a8f76..ca9aba35 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+auth.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+auth.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension HTTPClientRequest { diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift index af88030b..efe128fb 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift @@ -57,12 +57,20 @@ public struct HTTPClientRequest: Sendable { /// Request-specific TLS configuration, defaults to no request-specific TLS configuration. public var tlsConfiguration: TLSConfiguration? + /// The local IP address to bind this request's connection to. + /// + /// When set, overrides ``HTTPClient/Configuration/localAddress`` for this request. + /// The value should be an IP address string (e.g. `"192.168.1.10"` or `"::1"`). + /// Defaults to `nil` (use client configuration default). + public var localAddress: String? + public init(url: String) { self.url = url self.method = .GET self.headers = .init() self.body = .none self.tlsConfiguration = nil + self.localAddress = nil } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientResponse.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientResponse.swift index 1803c9cf..801ad7fb 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientResponse.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientResponse.swift @@ -15,7 +15,11 @@ import NIOCore import NIOHTTP1 +#if canImport(FoundationEssentials) +import struct FoundationEssentials.URL +#else import struct Foundation.URL +#endif /// A representation of an HTTP response for the Swift Concurrency HTTPClient API. /// diff --git a/Sources/AsyncHTTPClient/BasicAuth.swift b/Sources/AsyncHTTPClient/BasicAuth.swift index 3e69f827..2a0260eb 100644 --- a/Sources/AsyncHTTPClient/BasicAuth.swift +++ b/Sources/AsyncHTTPClient/BasicAuth.swift @@ -12,9 +12,14 @@ // //===----------------------------------------------------------------------===// -import Foundation import NIOHTTP1 +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + /// Generates base64 encoded username + password for http basic auth. /// /// - Parameters: diff --git a/Sources/AsyncHTTPClient/ConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool.swift index 3b45eca0..a659df27 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool.swift @@ -49,17 +49,20 @@ enum ConnectionPool { var connectionTarget: ConnectionTarget private var tlsConfiguration: BestEffortHashableTLSConfiguration? var serverNameIndicatorOverride: String? + var localAddress: String? init( scheme: Scheme, connectionTarget: ConnectionTarget, tlsConfiguration: BestEffortHashableTLSConfiguration? = nil, - serverNameIndicatorOverride: String? + serverNameIndicatorOverride: String?, + localAddress: String? = nil ) { self.scheme = scheme self.connectionTarget = connectionTarget self.tlsConfiguration = tlsConfiguration self.serverNameIndicatorOverride = serverNameIndicatorOverride + self.localAddress = localAddress } var description: String { @@ -75,8 +78,12 @@ enum ConnectionPool { case .unixSocket(let socketPath): hostDescription = socketPath } - return + var result = "\(self.scheme)://\(hostDescription)\(self.serverNameIndicatorOverride.map { " SNI: \($0)" } ?? "") TLS-hash: \(hash)" + if let addr = self.localAddress { + result += " bind: \(addr)" + } + return result } } } @@ -97,7 +104,12 @@ extension DeconstructedURL { } extension ConnectionPool.Key { - init(url: DeconstructedURL, tlsConfiguration: TLSConfiguration?, dnsOverride: [String: String]) { + init( + url: DeconstructedURL, + tlsConfiguration: TLSConfiguration?, + dnsOverride: [String: String], + localAddress: String? = nil + ) { let (connectionTarget, serverNameIndicatorOverride) = url.applyDNSOverride(dnsOverride) self.init( scheme: url.scheme, @@ -105,15 +117,17 @@ extension ConnectionPool.Key { tlsConfiguration: tlsConfiguration.map { BestEffortHashableTLSConfiguration(wrapping: $0) }, - serverNameIndicatorOverride: serverNameIndicatorOverride + serverNameIndicatorOverride: serverNameIndicatorOverride, + localAddress: localAddress ) } - init(_ request: HTTPClient.Request, dnsOverride: [String: String] = [:]) { + init(_ request: HTTPClient.Request, dnsOverride: [String: String] = [:], localAddress: String? = nil) { self.init( url: request.deconstructedURL, tlsConfiguration: request.tlsConfiguration, - dnsOverride: dnsOverride + dnsOverride: dnsOverride, + localAddress: localAddress ) } } diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTP1/HTTP1ClientChannelHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTP1/HTTP1ClientChannelHandler.swift index 9ca82f9a..e59f0ab7 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTP1/HTTP1ClientChannelHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTP1/HTTP1ClientChannelHandler.swift @@ -243,24 +243,32 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler { context.writeAndFlush(self.wrapOutboundOut(.body(part)), promise: writePromise) case .sendRequestEnd(let trailers, let writePromise, let finalAction): - - let writePromise = writePromise ?? context.eventLoop.makePromise(of: Void.self) // We need to defer succeeding the old request to avoid ordering issues + let writePromise = writePromise ?? context.eventLoop.makePromise(of: Void.self) + // It is fine to bang the request here, as we have just verified with the state machine + // that the request is still ongoing. + // TODO: In the future, we should likely move the request into the state machine to + // prevent diverging state. + let oldRequest = self.request! + + switch finalAction { + case .none: + // we must not nil out the request here, as we are still uploading the request + // and therefore still need the reference to it. + break + case .informConnectionIsIdle: + self.request = nil + case .close: + self.request = nil + } writePromise.futureResult.hop(to: context.eventLoop).assumeIsolated().whenComplete { result in - guard let oldRequest = self.request else { - // in the meantime an error might have happened, which is why this request is - // not reference anymore. - return - } - oldRequest.requestBodyStreamSent() switch result { case .success: // If our final action is not `none`, that means we've already received // the complete response. As a result, once we've uploaded all the body parts // we need to tell the pool that the connection is idle or, if we were asked to // close when we're done, send the close. Either way, we then succeed the request - switch finalAction { case .none: // we must not nil out the request here, as we are still uploading the request @@ -268,13 +276,12 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler { break case .informConnectionIsIdle: - self.request = nil self.onConnectionIdle() case .close: - self.request = nil context.close(promise: nil) } + oldRequest.requestBodyStreamSent() case .failure(let error): context.close(promise: nil) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index 3dc47c5a..a9679a3d 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -245,14 +245,19 @@ extension HTTPConnectionPool.ConnectionFactory { promise: EventLoopPromise ) { precondition(!self.key.scheme.usesTLS, "Unexpected scheme") - return self.makePlainBootstrap( - requester: requester, - connectionID: connectionID, - deadline: deadline, - eventLoop: eventLoop - ).connect(target: self.key.connectionTarget).map { - .http1_1($0) - }.cascade(to: promise) + do { + let bootstrap = try self.makePlainBootstrap( + requester: requester, + connectionID: connectionID, + deadline: deadline, + eventLoop: eventLoop + ) + bootstrap.connect(target: self.key.connectionTarget).map { + .http1_1($0) + }.cascade(to: promise) + } catch { + promise.fail(error) + } } private func makeHTTPProxyChannel( @@ -267,12 +272,18 @@ extension HTTPConnectionPool.ConnectionFactory { // A proxy connection starts with a plain text connection to the proxy server. After // the connection has been established with the proxy server, the connection might be // upgraded to TLS before we send our first request. - let bootstrap = self.makePlainBootstrap( - requester: requester, - connectionID: connectionID, - deadline: deadline, - eventLoop: eventLoop - ) + let bootstrap: NIOClientTCPBootstrapProtocol + do { + bootstrap = try self.makePlainBootstrap( + requester: requester, + connectionID: connectionID, + deadline: deadline, + eventLoop: eventLoop + ) + } catch { + promise.fail(error) + return + } bootstrap.connect(host: proxy.host, port: proxy.port).whenComplete { result in switch result { case .success(let channel): @@ -321,12 +332,18 @@ extension HTTPConnectionPool.ConnectionFactory { // A proxy connection starts with a plain text connection to the proxy server. After // the connection has been established with the proxy server, the connection might be // upgraded to TLS before we send our first request. - let bootstrap = self.makePlainBootstrap( - requester: requester, - connectionID: connectionID, - deadline: deadline, - eventLoop: eventLoop - ) + let bootstrap: NIOClientTCPBootstrapProtocol + do { + bootstrap = try self.makePlainBootstrap( + requester: requester, + connectionID: connectionID, + deadline: deadline, + eventLoop: eventLoop + ) + } catch { + promise.fail(error) + return + } bootstrap.connect(host: proxy.host, port: proxy.port).whenComplete { result in switch result { case .success(let channel): @@ -421,12 +438,16 @@ extension HTTPConnectionPool.ConnectionFactory { connectionID: HTTPConnectionPool.Connection.ID, deadline: NIODeadline, eventLoop: EventLoop - ) -> NIOClientTCPBootstrapProtocol { + ) throws -> NIOClientTCPBootstrapProtocol { + if let localAddress = self.key.localAddress, !localAddress.isIPAddress { + throw HTTPClientError.invalidLocalAddress + } + #if canImport(Network) if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), let tsBootstrap = NIOTSConnectionBootstrap(validatingGroup: eventLoop) { - return + var bootstrap = tsBootstrap .channelOption( NIOTSChannelOptions.waitForActivity, @@ -448,14 +469,32 @@ extension HTTPConnectionPool.ConnectionFactory { return channel.eventLoop.makeFailedFuture(error) } } + if let localAddress = self.key.localAddress { + bootstrap = bootstrap.configureNWParameters { params in + params.requiredLocalEndpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host(localAddress), + port: .any + ) + } + } + return bootstrap } #endif if let nioBootstrap = ClientBootstrap(validatingGroup: eventLoop) { - return + var bootstrap = nioBootstrap .connectTimeout(deadline - NIODeadline.now()) .enableMPTCP(clientConfiguration.enableMultipath) + if let localAddress = self.key.localAddress { + do { + let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0) + bootstrap = bootstrap.bind(to: socketAddress) + } catch { + throw HTTPClientError.invalidLocalAddress + } + } + return bootstrap } preconditionFailure("No matching bootstrap found") @@ -523,6 +562,10 @@ extension HTTPConnectionPool.ConnectionFactory { eventLoop: EventLoop, logger: Logger ) -> EventLoopFuture { + if let localAddress = self.key.localAddress, !localAddress.isIPAddress { + return eventLoop.makeFailedFuture(HTTPClientError.invalidLocalAddress) + } + var tlsConfig = self.tlsConfiguration switch self.clientConfiguration.httpVersion.configuration { case .automatic: @@ -538,13 +581,14 @@ extension HTTPConnectionPool.ConnectionFactory { #if canImport(Network) if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), eventLoop is QoSEventLoop { // create NIOClientTCPBootstrap with NIOTS TLS provider + let localAddr = self.key.localAddress let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions( on: eventLoop, serverNameIndicatorOverride: key.serverNameIndicatorOverride ).map { options -> NIOClientTCPBootstrapProtocol in - NIOTSConnectionBootstrap(group: eventLoop) // validated above + var bootstrap = NIOTSConnectionBootstrap(group: eventLoop) // validated above .channelOption( NIOTSChannelOptions.waitForActivity, value: self.clientConfiguration.networkFrameworkWaitForConnectivity @@ -569,7 +613,16 @@ extension HTTPConnectionPool.ConnectionFactory { } catch { return channel.eventLoop.makeFailedFuture(error) } - } as NIOClientTCPBootstrapProtocol + } + if let localAddress = localAddr { + bootstrap = bootstrap.configureNWParameters { params in + params.requiredLocalEndpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host(localAddress), + port: .any + ) + } + } + return bootstrap as NIOClientTCPBootstrapProtocol } return bootstrapFuture } @@ -581,10 +634,20 @@ extension HTTPConnectionPool.ConnectionFactory { logger: logger ) - return eventLoop.submit { - ClientBootstrap(group: eventLoop) + return eventLoop.submit { [key] () throws -> NIOClientTCPBootstrapProtocol in + var bootstrap = ClientBootstrap(group: eventLoop) .connectTimeout(deadline - NIODeadline.now()) .enableMPTCP(clientConfiguration.enableMultipath) + if let localAddress = key.localAddress { + do { + let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0) + bootstrap = bootstrap.bind(to: socketAddress) + } catch { + throw HTTPClientError.invalidLocalAddress + } + } + return + bootstrap .channelInitializer { channel in sslContextFuture.flatMap { sslContext -> EventLoopFuture in do { diff --git a/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift b/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift index 903f962e..bf33a95b 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift @@ -21,15 +21,20 @@ struct RequestOptions { var idleWriteTimeout: TimeAmount? /// DNS overrides. var dnsOverride: [String: String] + /// The local IP address to bind outgoing connections to. This is typically used on multi-NIC + /// systems where we want to control where traffic goes. + var localAddress: String? init( idleReadTimeout: TimeAmount?, idleWriteTimeout: TimeAmount?, - dnsOverride: [String: String] + dnsOverride: [String: String], + localAddress: String? = nil ) { self.idleReadTimeout = idleReadTimeout self.idleWriteTimeout = idleWriteTimeout self.dnsOverride = dnsOverride + self.localAddress = localAddress } } @@ -38,7 +43,8 @@ extension RequestOptions { RequestOptions( idleReadTimeout: configuration.timeout.read, idleWriteTimeout: configuration.timeout.write, - dnsOverride: configuration.dnsOverride + dnsOverride: configuration.dnsOverride, + localAddress: configuration.localAddress ) } } diff --git a/Sources/AsyncHTTPClient/DeconstructedURL.swift b/Sources/AsyncHTTPClient/DeconstructedURL.swift index f7d0b197..96a16d87 100644 --- a/Sources/AsyncHTTPClient/DeconstructedURL.swift +++ b/Sources/AsyncHTTPClient/DeconstructedURL.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import struct FoundationEssentials.URL +#else import struct Foundation.URL +#endif struct DeconstructedURL { var scheme: Scheme diff --git a/Sources/AsyncHTTPClient/FileDownloadDelegate.swift b/Sources/AsyncHTTPClient/FileDownloadDelegate.swift index 33a4d3cb..4076d955 100644 --- a/Sources/AsyncHTTPClient/FileDownloadDelegate.swift +++ b/Sources/AsyncHTTPClient/FileDownloadDelegate.swift @@ -17,7 +17,11 @@ import NIOCore import NIOHTTP1 import NIOPosix +#if canImport(FoundationEssentials) +import struct FoundationEssentials.URL +#else import struct Foundation.URL +#endif /// Handles a streaming download to a given file path, allowing headers and progress to be reported. public final class FileDownloadDelegate: HTTPClientResponseDelegate { diff --git a/Sources/AsyncHTTPClient/FoundationExtensions.swift b/Sources/AsyncHTTPClient/FoundationExtensions.swift index 452cb7b1..eeff2ad7 100644 --- a/Sources/AsyncHTTPClient/FoundationExtensions.swift +++ b/Sources/AsyncHTTPClient/FoundationExtensions.swift @@ -15,7 +15,11 @@ // Extensions which provide better ergonomics when using Foundation types, // or by using Foundation APIs. +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif extension HTTPClient.Cookie { /// The cookie's expiration date. @@ -73,3 +77,66 @@ extension HTTPClient.Body { self.bytes(data) } } + +extension StringProtocol { + func addingPercentEncodingAllowingURLHost() -> String { + guard !self.isEmpty else { return String(self) } + + let percent = UInt8(ascii: "%") + let utf8Buffer = self.utf8 + let maxLength = utf8Buffer.count * 3 + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer in + var i = 0 + for byte in utf8Buffer { + if byte.isURLHostAllowed { + outputBuffer[i] = byte + i += 1 + } else { + outputBuffer[i] = percent + outputBuffer[i + 1] = hexToAscii(byte >> 4) + outputBuffer[i + 2] = hexToAscii(byte & 0xF) + i += 3 + } + } + return String(decoding: outputBuffer[.. UInt8 { + switch hex { + case 0x0: return UInt8(ascii: "0") + case 0x1: return UInt8(ascii: "1") + case 0x2: return UInt8(ascii: "2") + case 0x3: return UInt8(ascii: "3") + case 0x4: return UInt8(ascii: "4") + case 0x5: return UInt8(ascii: "5") + case 0x6: return UInt8(ascii: "6") + case 0x7: return UInt8(ascii: "7") + case 0x8: return UInt8(ascii: "8") + case 0x9: return UInt8(ascii: "9") + case 0xA: return UInt8(ascii: "A") + case 0xB: return UInt8(ascii: "B") + case 0xC: return UInt8(ascii: "C") + case 0xD: return UInt8(ascii: "D") + case 0xE: return UInt8(ascii: "E") + case 0xF: return UInt8(ascii: "F") + default: fatalError("Invalid hex digit: \(hex)") + } +} + +extension UInt8 { + fileprivate var isURLHostAllowed: Bool { + switch self { + case UInt8(ascii: "0")...UInt8(ascii: "9"), + UInt8(ascii: "A")...UInt8(ascii: "Z"), + UInt8(ascii: "a")...UInt8(ascii: "z"), + UInt8(ascii: "!"), UInt8(ascii: "$"), UInt8(ascii: "&"), UInt8(ascii: "'"), + UInt8(ascii: "("), UInt8(ascii: ")"), UInt8(ascii: "*"), UInt8(ascii: "+"), + UInt8(ascii: ","), UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: ";"), + UInt8(ascii: "="), UInt8(ascii: "_"), UInt8(ascii: "~"): + return true + default: return false + } + } +} diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 1aa37fb7..dbc40984 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import Atomics -import Foundation +import Dispatch import Logging import NIOConcurrencyHelpers import NIOCore @@ -22,9 +22,18 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTLS -import NIOTransportServices import Tracing +#if canImport(Network) +import NIOTransportServices +#endif + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + extension Logger { private func requestInfo(_ request: HTTPClient.Request) -> Logger.Metadata.Value { "\(request.method) \(request.url)" @@ -895,6 +904,20 @@ public final class HTTPClient: Sendable { /// By default, don't use it public var enableMultipath: Bool + /// The local IP address to bind outgoing connections to. + /// + /// When set, all outgoing connections will bind to this address before connecting. + /// The value should be an IP address string (e.g. `"192.168.1.10"` or `"::1"`). + /// Port 0 (OS-assigned ephemeral port) is always used. + /// + /// This is most commonly used on multi-NIC systems where you want traffic to take a + /// specific network path which is not the choice the routing table would make by + /// default. + /// + /// This can be overridden on a per-request basis using ``HTTPClientRequest/localAddress``. + /// Defaults to `nil` (OS default interface selection). + public var localAddress: String? + /// A method with access to the HTTP/1 connection channel that is called when creating the connection. public var http1_1ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? @@ -925,6 +948,7 @@ public final class HTTPClient: Sendable { self.httpVersion = .automatic self.networkFrameworkWaitForConnectivity = true self.enableMultipath = false + self.localAddress = nil } public init( @@ -1452,6 +1476,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case invalidRedirectConfiguration case invalidHTTPVersionConfiguration case invalidDNSOverridesConfiguration + case invalidLocalAddress case internalStateFailure(file: String, line: UInt) } @@ -1545,6 +1570,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case .invalidDNSOverridesConfiguration: return "The DNS overrides specified in the configuration are not valid. Please specify in the format hostname1:ip1,hostname2:ip2" + case .invalidLocalAddress: + return "Invalid local address" case .internalStateFailure(let file, let line): return "An internal state failure has occurred (File: \(file), line: \(line)). Please open an issue with a reproducer if possible" @@ -1648,6 +1675,9 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { /// The DNS overrides specified in the configuration are not valid. public static let invalidDNSOverridesConfiguration = HTTPClientError(code: .invalidDNSOverridesConfiguration) + /// The local address specified is not a valid IP address. + public static let invalidLocalAddress = HTTPClientError(code: .invalidLocalAddress) + /// A state machine has reached an unsupported state, that wasn't considered when implementing. public static func internalStateFailure(file: String = #fileID, line: UInt = #line) -> HTTPClientError { HTTPClientError(code: .internalStateFailure(file: file, line: line)) diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 9c7becb0..4c4cc6f9 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -13,7 +13,6 @@ //===----------------------------------------------------------------------===// import Algorithms -import Foundation import Logging import NIOConcurrencyHelpers import NIOCore @@ -22,6 +21,12 @@ import NIOPosix import NIOSSL import Tracing +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + extension HTTPClient { /// A request body. public struct Body: Sendable { @@ -880,7 +885,7 @@ extension URL { /// - socketPath: The path to the unix domain socket to connect to. /// - uri: The URI path and query that will be sent to the server. public init?(httpURLWithSocketPath socketPath: String, uri: String = "/") { - guard let host = socketPath.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { return nil } + let host = socketPath.addingPercentEncodingAllowingURLHost() var urlString: String if uri.hasPrefix("/") { urlString = "http+unix://\(host)\(uri)" @@ -895,7 +900,7 @@ extension URL { /// - socketPath: The path to the unix domain socket to connect to. /// - uri: The URI path and query that will be sent to the server. public init?(httpsURLWithSocketPath socketPath: String, uri: String = "/") { - guard let host = socketPath.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { return nil } + let host = socketPath.addingPercentEncodingAllowingURLHost() var urlString: String if uri.hasPrefix("/") { urlString = "https+unix://\(host)\(uri)" diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift b/Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift index 148b4a4c..704329b8 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift @@ -14,10 +14,10 @@ import NIOCore import NIOHTTP1 -import NIOTransportServices #if canImport(Network) import Network +import NIOTransportServices #endif extension HTTPClient { diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift index e8278e09..ad3e6507 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift @@ -14,7 +14,12 @@ #if canImport(Network) +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif +import Dispatch import Network import NIOCore import NIOSSL diff --git a/Sources/AsyncHTTPClient/RedirectState.swift b/Sources/AsyncHTTPClient/RedirectState.swift index 4e14712d..9665c03d 100644 --- a/Sources/AsyncHTTPClient/RedirectState.swift +++ b/Sources/AsyncHTTPClient/RedirectState.swift @@ -14,7 +14,11 @@ import NIOHTTP1 +#if canImport(FoundationEssentials) +import struct FoundationEssentials.URL +#else import struct Foundation.URL +#endif typealias RedirectMode = HTTPClient.Configuration.RedirectConfiguration.Mode diff --git a/Sources/AsyncHTTPClient/RequestBag+StateMachine.swift b/Sources/AsyncHTTPClient/RequestBag+StateMachine.swift index 7accdc51..8f5cd37c 100644 --- a/Sources/AsyncHTTPClient/RequestBag+StateMachine.swift +++ b/Sources/AsyncHTTPClient/RequestBag+StateMachine.swift @@ -15,7 +15,11 @@ import NIOCore import NIOHTTP1 +#if canImport(FoundationEssentials) +import struct FoundationEssentials.URL +#else import struct Foundation.URL +#endif extension HTTPClient { /// The maximum body size allowed, before a redirect response is cancelled. 3KB. diff --git a/Sources/AsyncHTTPClient/RequestBag+Tracing.swift b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift index 729b6256..5551459d 100644 --- a/Sources/AsyncHTTPClient/RequestBag+Tracing.swift +++ b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift @@ -22,7 +22,7 @@ import Tracing extension RequestBag.LoopBoundState { /// Starts the "overall" Span that encompases the beginning of a request until receipt of the head part of the response. - mutating func startRequestSpan(tracer: T?) { + mutating func startRequestSpan(tracer: (any Sendable)?) { guard #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), let tracer = tracer as! (any Tracer)? else { diff --git a/Sources/AsyncHTTPClient/RequestBag.swift b/Sources/AsyncHTTPClient/RequestBag.swift index 67385e3f..1122aa8e 100644 --- a/Sources/AsyncHTTPClient/RequestBag.swift +++ b/Sources/AsyncHTTPClient/RequestBag.swift @@ -96,7 +96,11 @@ final class RequestBag: Sendabl requestOptions: RequestOptions, delegate: Delegate ) throws { - self.poolKey = .init(request, dnsOverride: requestOptions.dnsOverride) + self.poolKey = .init( + request, + dnsOverride: requestOptions.dnsOverride, + localAddress: requestOptions.localAddress + ) self.eventLoopPreference = eventLoopPreference self.task = task diff --git a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift index 43430b85..78dec429 100644 --- a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift +++ b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift @@ -1108,6 +1108,136 @@ final class AsyncAwaitEndToEndTests: XCTestCase { XCTAssertEqual(responseGet.history[1].request.method, .GET, "Redirected request should remain GET") } } + + // MARK: - Integration tests: local address binding + + func testLocalAddressBinding_configLevel() async throws { + // On Linux, 127.0.0.0/8 all route to loopback, so we can use a + // non-default address to prove the bind actually happened. + #if os(Linux) + let localAddress = "127.0.0.127" + #else + let localAddress = "127.0.0.1" + #endif + + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = localAddress + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-ip") + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + XCTAssertEqual(requestInfo?.data, localAddress) + } + + func testLocalAddressBinding_perRequest() async throws { + #if os(Linux) + let localAddress = "127.0.0.127" + #else + let localAddress = "127.0.0.1" + #endif + + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + var request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-ip") + request.localAddress = localAddress + + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + XCTAssertEqual(requestInfo?.data, localAddress) + } + + func testLocalAddressBinding_perRequestOverridesConfig() async throws { + #if os(Linux) + let localAddress = "127.0.0.127" + #else + let localAddress = "127.0.0.1" + #endif + + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + var request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-ip") + request.localAddress = localAddress + + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + XCTAssertEqual(requestInfo?.data, localAddress) + } + + func testLocalAddressBinding_invalidAddress() async throws { + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "not-a-valid-ip" + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "http://127.0.0.1/ok") + do { + _ = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTFail("Expected error to be thrown") + } catch { + XCTAssertEqual(error as? HTTPClientError, .invalidLocalAddress) + } + } + + func testLocalAddressBinding_withTLS() async throws { + #if os(Linux) + let localAddress = "127.0.0.127" + #else + let localAddress = "127.0.0.1" + #endif + + let bin = HTTPBin(.http2(compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.tlsConfiguration = .clientDefault + config.tlsConfiguration?.certificateVerification = .none + config.localAddress = localAddress + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "https://127.0.0.1:\(bin.port)/echo-client-ip") + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + XCTAssertEqual(requestInfo?.data, localAddress) + } } struct AnySendableSequence: @unchecked Sendable { diff --git a/Tests/AsyncHTTPClientTests/ConnectionPoolSizeConfigValueIsRespectedTests.swift b/Tests/AsyncHTTPClientTests/ConnectionPoolSizeConfigValueIsRespectedTests.swift index 96279133..1bdaba2e 100644 --- a/Tests/AsyncHTTPClientTests/ConnectionPoolSizeConfigValueIsRespectedTests.swift +++ b/Tests/AsyncHTTPClientTests/ConnectionPoolSizeConfigValueIsRespectedTests.swift @@ -23,11 +23,11 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif final class ConnectionPoolSizeConfigValueIsRespectedTests: XCTestCaseHTTPClientTestsBaseClass { diff --git a/Tests/AsyncHTTPClientTests/FoundationExtensionTests.swift b/Tests/AsyncHTTPClientTests/FoundationExtensionTests.swift new file mode 100644 index 00000000..4ff0d6c2 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/FoundationExtensionTests.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import AsyncHTTPClient + +@Suite +struct FoundationExtensionTests { + @Test(arguments: [ + // Format: (input, expected) + ("localhost", "localhost"), // Alphanumerics (No encoding needed) + ("example.com", "example.com"), // Domain with unreserved dot (No encoding needed) + ("user@email.com", "user%40email.com"), // '@' is not allowed in host (Should be encoded to %40) + ("!$&'()*+,;=[]", "!$&\'()*+,;=%5B%5D"), // Sub-delimiters and brackets (Allowed in host, NO encoding) + ("~_.-", "~_.-"), // Unreserved punctuation (Allowed in host, NO encoding) + ("café", "caf%C3%A9"), // Non-ASCII character (Should be encoded) + ("👨‍💻 swift", "%F0%9F%91%A8%E2%80%8D%F0%9F%92%BB%20swift"), // Emoji and space (Space to %20, Emoji encoded) + ("", ""), // Empty string + ("100% coverage", "100%25%20coverage"), // '%' symbol itself (Must be encoded to %25) + ("sub.domain_test~1.com", "sub.domain_test~1.com"), // Mix of allowed characters + // Invalid host chars like '/', '?', and '#' (Should be encoded), '=' is a valid sub-delimiter + ("path/to/api?query=1#frag", "path%2Fto%2Fapi%3Fquery=1%23frag"), + ]) + func addingPercentEncodingAllowingURLHost(input: String, expected: String) { + let foundationResult = input.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + let customResult = input.addingPercentEncodingAllowingURLHost() + + #expect( + customResult == expected, + "Encoding mismatch for input: '\(input)'. Expected '\(String(describing: expected))', got '\(String(describing: customResult))'" + ) + + #expect( + customResult == foundationResult, + "Result did not match Foundation: '\(input)'. Expected '\(String(describing: foundationResult))', got '\(String(describing: customResult))'" + ) + } +} diff --git a/Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift b/Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift index 401c14ff..f0f27868 100644 --- a/Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift @@ -947,6 +947,121 @@ class HTTP1ClientChannelHandlerTests: XCTestCase { ) } + func testDemandResponseBodyStreamAfterEarlyResponseDoesNotCrash() async throws { + // This test reproduces a crash where `demandMoreResponseBodyParts()` was called on the + // state machine after it had already transitioned to `.idle`. The scenario is: + // + // 1. A streaming POST request is in progress + // 2. The full response (head + end) arrives before the request body is finished + // 3. The response end is forwarded with `finalAction: .none` (body still uploading) + // 4. The request body stream finishes -> `requestStreamFinished` -> state machine + // transitions to `.idle` and returns `.sendRequestEnd(.informConnectionIsIdle)` + // 5. The `.sendRequestEnd` handler writes `.end` to the channel and registers a + // callback on the write promise + // 6. Before the write callback fires (write hasn't completed yet), + // `demandResponseBodyStream` is called (from a delegate on another event loop) + // 7. Without the fix, `self.request` was still set (only nilled in the write + // callback), so the guard passed and `demandMoreResponseBodyParts()` hit + // `fatalError("Invalid state: idle")` + // + // The fix nils out `self.request` synchronously in `.sendRequestEnd` (before the + // write callback), so the guard in `demandResponseBodyStream0` fails and returns early. + + final class DelayEndHandler: ChannelOutboundHandler { + typealias OutboundIn = HTTPClientRequestPart + typealias OutboundOut = HTTPClientRequestPart + + private(set) var endPromise: EventLoopPromise? + + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + if case .end = self.unwrapOutboundIn(data) { + self.endPromise = promise + context.write(data, promise: nil) + } else { + context.write(data, promise: promise) + } + } + } + + let eventLoop = EmbeddedEventLoop() + let delayEndHandler = DelayEndHandler() + let handler = HTTP1ClientChannelHandler( + eventLoop: eventLoop, + backgroundLogger: Logger(label: "no-op", factory: SwiftLogNoOpLogHandler.init), + connectionIdLoggerMetadata: "test connection" + ) + var connectionIsIdle = false + handler.onConnectionIdle = { connectionIsIdle = true } + let channel = EmbeddedChannel(handlers: [delayEndHandler, handler], loop: eventLoop) + XCTAssertNoThrow(try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 80)).wait()) + + let request = MockHTTPExecutableRequest( + head: .init(version: .http1_1, method: .POST, uri: "http://localhost/"), + framingMetadata: RequestFramingMetadata(connectionClose: false, body: .stream), + raiseErrorIfUnimplementedMethodIsCalled: false + ) + + let executor = handler.requestExecutor + + // When the body stream is resumed, write one part but do NOT finish the stream yet. + request.resumeRequestBodyStreamCallback = { + executor.writeRequestBodyPart(.byteBuffer(.init(string: "Hello")), request: request, promise: nil) + } + + // Start the request + channel.write(request, promise: nil) + + // Verify request head was sent + XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .head(request.requestHead)) + // Verify body part was sent + XCTAssertEqual( + try channel.readOutbound(as: HTTPClientRequestPart.self), + .body(.byteBuffer(.init(string: "Hello"))) + ) + + // Now send the full response while the request body stream is still open. + // This causes forwardResponseEnd with finalAction: .none (body not done yet). + XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.head(.init(version: .http1_1, status: .ok)))) + // Issue a read to advance the response stream state so it accepts the end properly. + channel.read() + XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.end(nil))) + + // Finish the request body stream. This transitions the state machine to `.idle` + // and writes `.end` to the channel. The DelayEndHandler intercepts the `.end` + // write and holds the promise, preventing the write callback from firing. + executor.finishRequestBodyStream(trailers: nil, request: request, promise: nil) + + // Verify the .end was written through to the channel + XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + // At this point: + // - The state machine has transitioned to `.idle` + // - The write promise has NOT been fulfilled (held by DelayEndHandler) + // - In old code: self.request is still set (only nilled in the write callback) + // - In fixed code: self.request is already nil (nilled synchronously) + + // Now call demandResponseBodyStream, simulating a delegate on a different event + // loop calling it after receiving the response end but before the write completes. + // Without the fix, self.request is still set, the guard passes, and + // state.demandMoreResponseBodyParts() crashes with "Invalid state: idle". + // With the fix, self.request was already nilled, the guard fails, and this is a no-op. + executor.demandResponseBodyStream(request) + + // Complete the delayed write to clean up properly. + delayEndHandler.endPromise?.succeed(()) + eventLoop.run() + + XCTAssertTrue(connectionIsIdle) + + XCTAssertEqual( + request.events.map(\.kind), + [ + .willExecuteRequest, .requestHeadSent, .resumeRequestBodyStream, + .receiveResponseHead, .receiveResponseEnd, .requestBodySent, + ] + ) + } + func testDefaultMaxBufferSize() { if MemoryLayout.size == 8 { XCTAssertEqual(ResponseAccumulator.maxByteBufferSize, Int(UInt32.max)) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientBase.swift b/Tests/AsyncHTTPClientTests/HTTPClientBase.swift index 90ab12fe..cca04bfd 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientBase.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientBase.swift @@ -24,11 +24,11 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif class XCTestCaseHTTPClientTestsBaseClass: XCTestCase { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift index 4c2d24dc..eb43e8cd 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift @@ -16,13 +16,13 @@ import NIOConcurrencyHelpers import NIOCore import NIOPosix import NIOSSL -import NIOTransportServices import XCTest @testable import AsyncHTTPClient #if canImport(Network) import Network +import NIOTransportServices #endif class HTTPClientNIOTSTests: XCTestCase { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift index 1e9aacf2..ea80cee6 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift @@ -27,11 +27,15 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTLS -import NIOTransportServices import XCTest @testable import AsyncHTTPClient +#if canImport(Network) +import Network +import NIOTransportServices +#endif + #if canImport(xlocale) import xlocale #elseif canImport(locale_h) @@ -1047,6 +1051,13 @@ internal final class HTTPBinHandler: ChannelInboundHandler { } self.resps.append(HTTPResponseBuilder(status: .ok)) return + case "/echo-client-ip": + var builder = HTTPResponseBuilder(status: .ok) + let clientIP = context.channel.remoteAddress?.ipAddress ?? "unknown" + let buf = context.channel.allocator.buffer(string: clientIP) + builder.add(buf) + self.resps.append(builder) + return case "/echohostheader": var builder = HTTPResponseBuilder(status: .ok) let hostValue = req.headers["Host"].first ?? "" diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index 959e0f93..d7288fcf 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -26,11 +26,11 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { @@ -4644,6 +4644,33 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { ) } } + + func testLocalAddressBinding_configLevel() throws { + // On Linux, 127.0.0.0/8 all route to loopback, so we can use a + // non-default address to prove the bind actually happened. + #if os(Linux) + let localAddress = "127.0.0.127" + #else + let localAddress = "127.0.0.1" + #endif + + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = localAddress + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let response = try client.get(url: "http://127.0.0.1:\(bin.port)/echo-client-ip").wait() + XCTAssertEqual(response.status, .ok) + + let bytes = response.body.flatMap { $0.getData(at: 0, length: $0.readableBytes) } + let data = try JSONDecoder().decode(RequestInfo.self, from: bytes!) + XCTAssertEqual(data.data, localAddress) + } } final class CountingDebugInitializerUtil: Sendable { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift index 53f1138b..3b9e86c5 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift @@ -24,7 +24,6 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import Tracing import XCTest @@ -32,6 +31,7 @@ import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif private func makeTracedHTTPClient(tracer: InMemoryTracer) -> HTTPClient { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift index 047c66e6..7e8d09d1 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift @@ -25,12 +25,12 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import Tracing import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif private func makeTracedHTTPClient(tracer: InMemoryTracer) -> HTTPClient { diff --git a/Tests/AsyncHTTPClientTests/IdleTimeoutNoReuseTests.swift b/Tests/AsyncHTTPClientTests/IdleTimeoutNoReuseTests.swift index e9a0d46d..bc44012e 100644 --- a/Tests/AsyncHTTPClientTests/IdleTimeoutNoReuseTests.swift +++ b/Tests/AsyncHTTPClientTests/IdleTimeoutNoReuseTests.swift @@ -23,11 +23,11 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif final class TestIdleTimeoutNoReuse: XCTestCaseHTTPClientTestsBaseClass { diff --git a/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift b/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift new file mode 100644 index 00000000..caf7c56b --- /dev/null +++ b/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift @@ -0,0 +1,133 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOHTTP1 +import Testing + +import struct Foundation.URL + +@testable import AsyncHTTPClient + +struct LocalAddressOverrideTests { + // MARK: - Pool Key with localAddress + + @Test func poolKeysWithDifferentLocalAddressesAreNotEqual() { + let key1 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10" + ) + let key2 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "10.0.0.1" + ) + let keyNil = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: nil + ) + #expect(key1 != key2) + #expect(key1 != keyNil) + #expect(key2 != keyNil) + } + + @Test func poolKeysWithSameLocalAddressAreEqual() { + let key1 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10" + ) + let key2 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10" + ) + #expect(key1 == key2) + } + + @Test func poolKeyWithNilLocalAddressMatchesDefault() { + let key1 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil + ) + let key2 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: nil + ) + #expect(key1 == key2) + } + + // MARK: - Per-request localAddress override + + @Test func perRequestLocalAddressOverridesConfig() throws { + var request = HTTPClientRequest(url: "https://example.com/get") + request.localAddress = "10.0.0.1" + + let prepared = try HTTPClientRequest.Prepared( + request, + localAddress: "192.168.1.10" + ) + + #expect(prepared.poolKey.localAddress == "10.0.0.1") + } + + @Test func configLocalAddressUsedWhenRequestHasNone() throws { + let request = HTTPClientRequest(url: "https://example.com/get") + + let prepared = try HTTPClientRequest.Prepared( + request, + localAddress: "192.168.1.10" + ) + + #expect(prepared.poolKey.localAddress == "192.168.1.10") + } + + @Test func noLocalAddressWhenNeitherSet() throws { + let request = HTTPClientRequest(url: "https://example.com/get") + + let prepared = try HTTPClientRequest.Prepared(request) + + #expect(prepared.poolKey.localAddress == nil) + } + + // MARK: - Redirect preserves localAddress + + @Test func redirectPreservesLocalAddress() { + var request = HTTPClientRequest(url: "https://example.com/redirect/301") + request.localAddress = "192.168.1.10" + + let redirected = request.followingRedirect( + from: URL(string: "https://example.com/redirect/301")!, + to: URL(string: "https://other.com/ok")!, + status: .movedPermanently, + config: .init( + max: 5, + allowCycles: false, + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: false + ) + ) + + #expect(redirected.localAddress == "192.168.1.10") + } +} diff --git a/Tests/AsyncHTTPClientTests/NoBytesSentOverBodyLimitTests.swift b/Tests/AsyncHTTPClientTests/NoBytesSentOverBodyLimitTests.swift index 026a45d4..32e8e5f4 100644 --- a/Tests/AsyncHTTPClientTests/NoBytesSentOverBodyLimitTests.swift +++ b/Tests/AsyncHTTPClientTests/NoBytesSentOverBodyLimitTests.swift @@ -23,11 +23,11 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif final class NoBytesSentOverBodyLimitTests: XCTestCaseHTTPClientTestsBaseClass { diff --git a/Tests/AsyncHTTPClientTests/RacePoolIdleConnectionsAndGetTests.swift b/Tests/AsyncHTTPClientTests/RacePoolIdleConnectionsAndGetTests.swift index 35a09c42..43c34811 100644 --- a/Tests/AsyncHTTPClientTests/RacePoolIdleConnectionsAndGetTests.swift +++ b/Tests/AsyncHTTPClientTests/RacePoolIdleConnectionsAndGetTests.swift @@ -23,11 +23,11 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif final class RacePoolIdleConnectionsAndGetTests: XCTestCaseHTTPClientTestsBaseClass { diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index a3ae5017..297e8170 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -1013,6 +1013,40 @@ final class RequestBagTests: XCTestCase { } XCTAssertTrue(isKnownUniquelyReferenced(&leakDetector)) } + + func testRequestBagPassesLocalAddressToPoolKey() throws { + let request = try HTTPClient.Request(url: "https://example.com/get") + let eventLoop = EmbeddedEventLoop() + defer { XCTAssertNoThrow(try eventLoop.syncShutdownGracefully()) } + let task = HTTPClient.Task(eventLoop: eventLoop, logger: .init(label: "test")) + let requestBag = try RequestBag( + request: request, + eventLoopPreference: .indifferent, + task: task, + redirectHandler: nil, + connectionDeadline: .distantFuture, + requestOptions: .forTests(localAddress: "10.0.0.1"), + delegate: ResponseAccumulator(request: request) + ) + XCTAssertEqual(requestBag.poolKey.localAddress, "10.0.0.1") + } + + func testRequestBagPoolKeyNilLocalAddressByDefault() throws { + let request = try HTTPClient.Request(url: "https://example.com/get") + let eventLoop = EmbeddedEventLoop() + defer { XCTAssertNoThrow(try eventLoop.syncShutdownGracefully()) } + let task = HTTPClient.Task(eventLoop: eventLoop, logger: .init(label: "test")) + let requestBag = try RequestBag( + request: request, + eventLoopPreference: .indifferent, + task: task, + redirectHandler: nil, + connectionDeadline: .distantFuture, + requestOptions: .forTests(), + delegate: ResponseAccumulator(request: request) + ) + XCTAssertNil(requestBag.poolKey.localAddress) + } } extension HTTPClient.Task { @@ -1137,12 +1171,14 @@ extension RequestOptions { static func forTests( idleReadTimeout: TimeAmount? = nil, idleWriteTimeout: TimeAmount? = nil, - dnsOverride: [String: String] = [:] + dnsOverride: [String: String] = [:], + localAddress: String? = nil ) -> Self { RequestOptions( idleReadTimeout: idleReadTimeout, idleWriteTimeout: idleWriteTimeout, - dnsOverride: dnsOverride + dnsOverride: dnsOverride, + localAddress: localAddress ) } } diff --git a/Tests/AsyncHTTPClientTests/Resources/example.com.cert.pem b/Tests/AsyncHTTPClientTests/Resources/example.com.cert.pem index f16590cd..afdb60b5 100644 --- a/Tests/AsyncHTTPClientTests/Resources/example.com.cert.pem +++ b/Tests/AsyncHTTPClientTests/Resources/example.com.cert.pem @@ -1,12 +1,12 @@ -----BEGIN CERTIFICATE----- -MIIBwTCCAUigAwIBAgIUX7f9BABxGdAqG5EvLpQScFt9lOkwCgYIKoZIzj0EAwMw +MIIBwTCCAUigAwIBAgIUPvvxS7euko8ZalJR2qIMlbfuA5MwCgYIKoZIzj0EAwMw KjEUMBIGA1UECgwLU2VsZiBTaWduZWQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0y -NTA0MDExNDMwMTFaFw0yNjA0MDExNDMwMTFaMCoxFDASBgNVBAoMC1NlbGYgU2ln -bmVkMRIwEAYDVQQDDAlsb2NhbGhvc3QwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQW -szfO5HCWIWgKUqyXUU0pFpYgaq01RRL69XZz1CkV6XTrxMfIvvwez2886EQDL8QX -i5NpKg3qvPgWuDjVHaj4WEJe5XMNqcujxcTufBlmaQ6o4vtoK7CIHDIDldF/HRij +NjA0MDIxNjMzMjJaFw0yNzA0MDIxNjMzMjJaMCoxFDASBgNVBAoMC1NlbGYgU2ln +bmVkMRIwEAYDVQQDDAlsb2NhbGhvc3QwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT5 +l93hAf9RYmRKfAE3JpbJIgsr1pHpjnUtzT99HTTLJFQpOSFqreZ7mPMzUGcrh/gW +0C59HpW2759OpbqxkC1zUcaQLiazLDGsprjXfHpFpJ5D033eefew/PysOQ5dFKej LzAtMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMBMGA1UdJQQMMAoGCCsGAQUFBwMB -MAoGCCqGSM49BAMDA2cAMGQCMBJ8Dxg0qX2bEZ3r6dI3UCGAUYxJDVk+XhiIY1Fm -5FJeQqhaVayCRPrPXXGZUJGY/wIwXej70FwkxHKLq+XxfHTC5CzmoOK469C9Rk9Y -ucddXM83ebFxVNgRCWetH9tDdXJ9 ------END CERTIFICATE----- \ No newline at end of file +MAoGCCqGSM49BAMDA2cAMGQCMFRSS0Ti9ndTwIt8Fg0ys9RyfuUj2JxDF4bIOF5g +hLGt1ZwPOukALbwGE5riOCk7gAIwQdOdW8y8UxnODjeWAoFAUeFDgFplhrpvJAnp +0sjx8oJH4Vd15Hvhoy7ZqeCh0O8P +-----END CERTIFICATE----- diff --git a/Tests/AsyncHTTPClientTests/Resources/example.com.private-key.pem b/Tests/AsyncHTTPClientTests/Resources/example.com.private-key.pem index 3ad9ce79..6d5af42a 100644 --- a/Tests/AsyncHTTPClientTests/Resources/example.com.private-key.pem +++ b/Tests/AsyncHTTPClientTests/Resources/example.com.private-key.pem @@ -1,6 +1,6 @@ -----BEGIN PRIVATE KEY----- -MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDD9v51MTOcgFIbiHbok -U+QOubosGF1u1q+D3fEUb1U2cgjCofKmPHekXTz0xu9MJi2hZANiAAQWszfO5HCW -IWgKUqyXUU0pFpYgaq01RRL69XZz1CkV6XTrxMfIvvwez2886EQDL8QXi5NpKg3q -vPgWuDjVHaj4WEJe5XMNqcujxcTufBlmaQ6o4vtoK7CIHDIDldF/HRg= ------END PRIVATE KEY----- \ No newline at end of file +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDA/iiVlk1ZaSDe6Tu7N +4Dxwa0AKR6U5OFy1U/VfvRbXs/IUJGi860oiquYLgny/eTehZANiAAT5l93hAf9R +YmRKfAE3JpbJIgsr1pHpjnUtzT99HTTLJFQpOSFqreZ7mPMzUGcrh/gW0C59HpW2 +759OpbqxkC1zUcaQLiazLDGsprjXfHpFpJ5D033eefew/PysOQ5dFKc= +-----END PRIVATE KEY----- diff --git a/Tests/AsyncHTTPClientTests/ResponseDelayGetTests.swift b/Tests/AsyncHTTPClientTests/ResponseDelayGetTests.swift index 5fd1d672..7874a185 100644 --- a/Tests/AsyncHTTPClientTests/ResponseDelayGetTests.swift +++ b/Tests/AsyncHTTPClientTests/ResponseDelayGetTests.swift @@ -23,11 +23,11 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif final class ResponseDelayGetTests: XCTestCaseHTTPClientTestsBaseClass { diff --git a/Tests/AsyncHTTPClientTests/StressGetHttpsTests.swift b/Tests/AsyncHTTPClientTests/StressGetHttpsTests.swift index 587e6c64..d5b77556 100644 --- a/Tests/AsyncHTTPClientTests/StressGetHttpsTests.swift +++ b/Tests/AsyncHTTPClientTests/StressGetHttpsTests.swift @@ -23,11 +23,11 @@ import NIOHTTPCompression import NIOPosix import NIOSSL import NIOTestUtils -import NIOTransportServices import XCTest #if canImport(Network) import Network +import NIOTransportServices #endif final class StressGetHttpsTests: XCTestCaseHTTPClientTestsBaseClass { diff --git a/Tests/LinkageTest/Package.swift b/Tests/LinkageTest/Package.swift new file mode 100644 index 00000000..c207514f --- /dev/null +++ b/Tests/LinkageTest/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "linkage-test", + dependencies: [ + .package(name: "async-http-client", path: "../..") + ], + targets: [ + .executableTarget( + name: "linkageTest", + dependencies: [ + .product(name: "AsyncHTTPClient", package: "async-http-client") + ] + ) + ] +) diff --git a/Tests/LinkageTest/Sources/linkageTest/main.swift b/Tests/LinkageTest/Sources/linkageTest/main.swift new file mode 100644 index 00000000..af48b95a --- /dev/null +++ b/Tests/LinkageTest/Sources/linkageTest/main.swift @@ -0,0 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AsyncHTTPClient + +print("\(HTTPClient.shared)") diff --git a/scripts/run-linkage-test.sh b/scripts/run-linkage-test.sh new file mode 100755 index 00000000..1686adc4 --- /dev/null +++ b/scripts/run-linkage-test.sh @@ -0,0 +1,51 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the AsyncHTTPClient open source project +## +## Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors +## Licensed under Apache License v2.0 +## +## See LICENSE.txt for license information +## See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +## +## SPDX-License-Identifier: Apache-2.0 +## +##===----------------------------------------------------------------------===## +set -eu + +# Validate that we're running on Linux +if [[ "$(uname -s)" != "Linux" ]]; then + echo "Error: This script must be run on Linux. Current OS: $(uname -s)" >&2 + exit 1 +fi + +echo "Running on Linux - proceeding with linkage test..." + +# Build the linkage test package +echo "Building linkage test package..." +swift build --package-path Tests/LinkageTest + +# Construct build path +build_path=$(swift build --package-path Tests/LinkageTest --show-bin-path) +binary_path=$build_path/linkageTest + +# Verify the binary exists +if [[ ! -f "$binary_path" ]]; then + echo "Error: Built binary not found at $binary_path" >&2 + exit 1 +fi + +echo "Checking linkage for binary: $binary_path" + +# Run ldd and check if libFoundation.so is linked +ldd_output=$(ldd "$binary_path") +echo "LDD output:" +echo "$ldd_output" + +if echo "$ldd_output" | grep -q "libFoundation.so"; then + echo "Error: Binary is linked against libFoundation.so - this indicates incorrect linkage. Ensure the full Foundation is not linked on Linux when default traits are disabled." >&2 + exit 1 +else + echo "Success: Binary is not linked against libFoundation.so - linkage test passed." +fi \ No newline at end of file