diff --git a/NOTICE.txt b/NOTICE.txt index cf22b8e7..7640185f 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -32,3 +32,14 @@ This product contains a derivation of the Tony Stone's 'process_test_files.rb'. * https://www.apache.org/licenses/LICENSE-2.0 * HOMEPAGE: * https://codegists.com/snippet/ruby/generate_xctest_linux_runnerrb_tonystone_ruby + +--- + +This product contains a derivation of "HTTP1ProxyConnectHandler.swift" and accompanying tests from AsyncHTTPClient. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/swift-server/async-http-client + +--- diff --git a/Package.swift b/Package.swift index e2fdf298..07157329 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ var targets: [PackageDescription.Target] = [ dependencies: [ .product(name: "NIO", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), ]), .target( name: "NIOHTTPCompression", diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift index 82dba3f2..45c09701 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.5.swift @@ -21,6 +21,7 @@ var targets: [PackageDescription.Target] = [ dependencies: [ .product(name: "NIO", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio") ]), .target( name: "NIOHTTPCompression", diff --git a/Sources/NIOExtras/HTTP1ProxyConnectHandler.swift b/Sources/NIOExtras/HTTP1ProxyConnectHandler.swift new file mode 100644 index 00000000..7663bdd8 --- /dev/null +++ b/Sources/NIOExtras/HTTP1ProxyConnectHandler.swift @@ -0,0 +1,396 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2022 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTP1 + +public final class NIOHTTP1ProxyConnectHandler: ChannelDuplexHandler, RemovableChannelHandler { + public typealias OutboundIn = Never + public typealias OutboundOut = HTTPClientRequestPart + public typealias InboundIn = HTTPClientResponsePart + + /// Whether we've already seen the first request. + private var seenFirstRequest = false + private var bufferedWrittenMessages: MarkedCircularBuffer + + struct BufferedWrite { + var data: NIOAny + var promise: EventLoopPromise? + } + + private enum State { + // transitions to `.connectSent` or `.failed` + case initialized + // transitions to `.headReceived` or `.failed` + case connectSent(Scheduled) + // transitions to `.completed` or `.failed` + case headReceived(Scheduled) + // final error state + case failed(Error) + // final success state + case completed + } + + private var state: State = .initialized + + private let targetHost: String + private let targetPort: Int + private let headers: HTTPHeaders + private let deadline: NIODeadline + private let promise: EventLoopPromise? + + /// Creates a new ``NIOHTTP1ProxyConnectHandler`` that issues a CONNECT request to a proxy server + /// and instructs the server to connect to `targetHost`. + /// - Parameters: + /// - targetHost: The desired end point host + /// - targetPort: The port to be used when connecting to `targetHost` + /// - headers: Headers to supply to the proxy server as part of the CONNECT request + /// - deadline: Deadline for the CONNECT request + /// - promise: Promise with which the result of the connect operation is communicated + public init(targetHost: String, + targetPort: Int, + headers: HTTPHeaders, + deadline: NIODeadline, + promise: EventLoopPromise?) { + self.targetHost = targetHost + self.targetPort = targetPort + self.headers = headers + self.deadline = deadline + self.promise = promise + + self.bufferedWrittenMessages = MarkedCircularBuffer(initialCapacity: 16) // matches CircularBuffer default + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch self.state { + case .initialized, .connectSent, .headReceived, .completed: + self.bufferedWrittenMessages.append(BufferedWrite(data: data, promise: promise)) + case .failed(let error): + promise?.fail(error) + } + } + + public func flush(context: ChannelHandlerContext) { + self.bufferedWrittenMessages.mark() + } + + public func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) { + // We have been formally removed from the pipeline. We should send any buffered data we have. + switch self.state { + case .initialized, .connectSent, .headReceived, .failed: + self.failWithError(.noResult(), context: context) + + case .completed: + while let (bufferedPart, isMarked) = self.bufferedWrittenMessages.popFirstCheckMarked() { + context.write(bufferedPart.data, promise: bufferedPart.promise) + if isMarked { + context.flush() + } + } + + } + + context.leavePipeline(removalToken: removalToken) + } + + public func handlerAdded(context: ChannelHandlerContext) { + if context.channel.isActive { + self.sendConnect(context: context) + } + } + + public func handlerRemoved(context: ChannelHandlerContext) { + switch self.state { + case .failed, .completed: + guard self.bufferedWrittenMessages.isEmpty else { + self.failWithError(Error.droppedWrites(), context: context) + return + } + break + + case .initialized, .connectSent, .headReceived: + self.failWithError(Error.noResult(), context: context) + } + } + + public func channelActive(context: ChannelHandlerContext) { + self.sendConnect(context: context) + context.fireChannelActive() + } + + public func channelInactive(context: ChannelHandlerContext) { + switch self.state { + case .initialized: + self.failWithError(Error.channelUnexpectedlyInactive(), context: context, closeConnection: false) + case .connectSent(let timeout), .headReceived(let timeout): + timeout.cancel() + self.failWithError(Error.remoteConnectionClosed(), context: context, closeConnection: false) + + case .failed, .completed: + break + } + context.fireChannelInactive() + } + + public func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch self.unwrapInboundIn(data) { + case .head(let head): + self.handleHTTPHeadReceived(head, context: context) + case .body: + self.handleHTTPBodyReceived(context: context) + case .end: + self.handleHTTPEndReceived(context: context) + } + } + + private func sendConnect(context: ChannelHandlerContext) { + guard case .initialized = self.state else { + // we might run into this handler twice, once in handlerAdded and once in channelActive. + return + } + + let timeout = context.eventLoop.scheduleTask(deadline: self.deadline) { + switch self.state { + case .initialized: + preconditionFailure("How can we have a scheduled timeout, if the connection is not even up?") + + case .connectSent, .headReceived: + self.failWithError(Error.httpProxyHandshakeTimeout(), context: context) + + case .failed, .completed: + break + } + } + + self.state = .connectSent(timeout) + + let head = HTTPRequestHead( + version: .init(major: 1, minor: 1), + method: .CONNECT, + uri: "\(self.targetHost):\(self.targetPort)", + headers: self.headers + ) + + context.write(self.wrapOutboundOut(.head(head)), promise: nil) + context.write(self.wrapOutboundOut(.end(nil)), promise: nil) + context.flush() + } + + private func handleHTTPHeadReceived(_ head: HTTPResponseHead, context: ChannelHandlerContext) { + switch self.state { + case .connectSent(let scheduled): + switch head.status.code { + case 200..<300: + // Any 2xx (Successful) response indicates that the sender (and all + // inbound proxies) will switch to tunnel mode immediately after the + // blank line that concludes the successful response's header section + self.state = .headReceived(scheduled) + case 407: + self.failWithError(Error.proxyAuthenticationRequired(), context: context) + + default: + // Any response other than a successful response indicates that the tunnel + // has not yet been formed and that the connection remains governed by HTTP. + self.failWithError(Error.invalidProxyResponseHead(head), context: context) + } + case .failed: + break + case .initialized, .headReceived, .completed: + preconditionFailure("Invalid state: \(self.state)") + } + } + + private func handleHTTPBodyReceived(context: ChannelHandlerContext) { + switch self.state { + case .headReceived(let timeout): + timeout.cancel() + // we don't expect a body + self.failWithError(Error.invalidProxyResponse(), context: context) + case .failed: + // ran into an error before... ignore this one + break + case .completed, .connectSent, .initialized: + preconditionFailure("Invalid state: \(self.state)") + } + } + + private func handleHTTPEndReceived(context: ChannelHandlerContext) { + switch self.state { + case .headReceived(let timeout): + timeout.cancel() + self.state = .completed + case .failed: + // ran into an error before... ignore this one + return + case .initialized, .connectSent, .completed: + preconditionFailure("Invalid state: \(self.state)") + } + + // Ok, we've set up the proxy connection. We can now remove ourselves, which should happen synchronously. + context.pipeline.removeHandler(context: context, promise: nil) + + self.promise?.succeed(()) + } + + private func failWithError(_ error: Error, context: ChannelHandlerContext, closeConnection: Bool = true) { + switch self.state { + case .failed: + return + case .initialized, .connectSent, .headReceived, .completed: + self.state = .failed(error) + self.promise?.fail(error) + context.fireErrorCaught(error) + if closeConnection { + context.close(mode: .all, promise: nil) + } + while let bufferedWrite = self.bufferedWrittenMessages.popFirst() { + bufferedWrite.promise?.fail(error) + } + } + } + + /// Error types for ``HTTP1ProxyConnectHandler`` + public struct Error: Swift.Error { + fileprivate enum Details { + case proxyAuthenticationRequired + case invalidProxyResponseHead(head: HTTPResponseHead) + case invalidProxyResponse + case remoteConnectionClosed + case httpProxyHandshakeTimeout + case noResult + case channelUnexpectedlyInactive + case droppedWrites + } + + final class Storage: Sendable { + fileprivate let details: Details + public let file: String + public let line: UInt + + fileprivate init(error details: Details, file: String, line: UInt) { + self.details = details + self.file = file + self.line = line + } + } + + fileprivate let store: Storage + + fileprivate init(error: Details, file: String, line: UInt) { + self.store = Storage(error: error, file: file, line: line) + } + + /// Proxy response status `407` indicates that authentication is required + public static func proxyAuthenticationRequired(file: String = #file, line: UInt = #line) -> Error { + Error(error: .proxyAuthenticationRequired, file: file, line: line) + } + + /// Proxy response contains unexpected status + public static func invalidProxyResponseHead(_ head: HTTPResponseHead, file: String = #file, line: UInt = #line) -> Error { + Error(error: .invalidProxyResponseHead(head: head), file: file, line: line) + } + + /// Proxy response contains unexpected body + public static func invalidProxyResponse(file: String = #file, line: UInt = #line) -> Error { + Error(error: .invalidProxyResponse, file: file, line: line) + } + + /// Connection has been closed for ongoing request + public static func remoteConnectionClosed(file: String = #file, line: UInt = #line) -> Error { + Error(error: .remoteConnectionClosed, file: file, line: line) + } + + /// Proxy connection handshake has timed out + public static func httpProxyHandshakeTimeout(file: String = #file, line: UInt = #line) -> Error { + Error(error: .httpProxyHandshakeTimeout, file: file, line: line) + } + + /// Handler was removed before we received a result for the request + public static func noResult(file: String = #file, line: UInt = #line) -> Error { + Error(error: .noResult, file: file, line: line) + } + + /// Handler became unexpectedly inactive before a connection was made + public static func channelUnexpectedlyInactive(file: String = #file, line: UInt = #line) -> Error { + Error(error: .channelUnexpectedlyInactive, file: file, line: line) + } + + public static func droppedWrites(file: String = #file, line: UInt = #line) -> Error { + Error(error: .droppedWrites, file: file, line: line) + } + + fileprivate var errorCode: Int { + switch self.store.details { + case .proxyAuthenticationRequired: + return 0 + case .invalidProxyResponseHead: + return 1 + case .invalidProxyResponse: + return 2 + case .remoteConnectionClosed: + return 3 + case .httpProxyHandshakeTimeout: + return 4 + case .noResult: + return 5 + case .channelUnexpectedlyInactive: + return 6 + case .droppedWrites: + return 7 + } + } + } + +} + +extension NIOHTTP1ProxyConnectHandler.Error: Hashable { + // compare only the kind of error, not the associated response head + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.errorCode == rhs.errorCode + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.errorCode) + } +} + + +extension NIOHTTP1ProxyConnectHandler.Error: CustomStringConvertible { + public var description: String { + "\(self.store.details.description) (\(self.store.file): \(self.store.line))" + } +} + +extension NIOHTTP1ProxyConnectHandler.Error.Details: CustomStringConvertible { + public var description: String { + switch self { + case .proxyAuthenticationRequired: + return "Proxy Authentication Required" + case .invalidProxyResponseHead(let head): + return "Invalid Proxy Response Head: \(head)" + case .invalidProxyResponse: + return "Invalid Proxy Response" + case .remoteConnectionClosed: + return "Remote Connection Closed" + case .httpProxyHandshakeTimeout: + return "HTTP Proxy Handshake Timeout" + case .noResult: + return "No Result" + case .channelUnexpectedlyInactive: + return "Channel Unexpectedly Inactive" + case .droppedWrites: + return "Handler Was Removed with Writes Left in the Buffer" + } + } +} diff --git a/Sources/NIOExtras/MarkedCircularBuffer+PopFirstCheckMarked.swift b/Sources/NIOExtras/MarkedCircularBuffer+PopFirstCheckMarked.swift new file mode 100644 index 00000000..03e1825b --- /dev/null +++ b/Sources/NIOExtras/MarkedCircularBuffer+PopFirstCheckMarked.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore + +extension MarkedCircularBuffer { + @inlinable + internal mutating func popFirstCheckMarked() -> (Element, Bool)? { + let marked = self.markedElementIndex == self.startIndex + return self.popFirst().map { ($0, marked) } + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 43260cc6..153f4f75 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -2,7 +2,7 @@ // // This source file is part of the SwiftNIO open source project // -// Copyright (c) 2018-2022 Apple Inc. and the SwiftNIO project authors +// Copyright (c) 2018-2023 Apple Inc. and the SwiftNIO project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -41,6 +41,7 @@ class LinuxMainRunner { testCase(DebugInboundEventsHandlerTest.allTests), testCase(DebugOutboundEventsHandlerTest.allTests), testCase(FixedLengthFrameDecoderTest.allTests), + testCase(HTTP1ProxyConnectHandlerTests.allTests), testCase(HTTPRequestCompressorTest.allTests), testCase(HTTPRequestDecompressorTest.allTests), testCase(HTTPResponseCompressorTest.allTests), diff --git a/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests+XCTest.swift b/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests+XCTest.swift new file mode 100644 index 00000000..e7a2d53a --- /dev/null +++ b/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests+XCTest.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +// +// HTTP1ProxyConnectHandlerTests+XCTest.swift +// +import XCTest + +/// +/// NOTE: This file was generated by generate_linux_tests.rb +/// +/// Do NOT edit this file directly as it will be regenerated automatically when needed. +/// + +extension HTTP1ProxyConnectHandlerTests { + + @available(*, deprecated, message: "not actually deprecated. Just deprecated to allow deprecated tests (which test deprecated functionality) without warnings") + static var allTests : [(String, (HTTP1ProxyConnectHandlerTests) -> () throws -> Void)] { + return [ + ("testProxyConnectWithoutAuthorizationSuccess", testProxyConnectWithoutAuthorizationSuccess), + ("testProxyConnectWithAuthorization", testProxyConnectWithAuthorization), + ("testProxyConnectWithoutAuthorizationFailure500", testProxyConnectWithoutAuthorizationFailure500), + ("testProxyConnectWithoutAuthorizationButAuthorizationNeeded", testProxyConnectWithoutAuthorizationButAuthorizationNeeded), + ("testProxyConnectReceivesBody", testProxyConnectReceivesBody), + ("testProxyConnectWithoutAuthorizationBufferedWrites", testProxyConnectWithoutAuthorizationBufferedWrites), + ("testProxyConnectFailsBufferedWritesAreFailed", testProxyConnectFailsBufferedWritesAreFailed), + ] + } +} + diff --git a/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests.swift b/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests.swift new file mode 100644 index 00000000..f2457d18 --- /dev/null +++ b/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests.swift @@ -0,0 +1,363 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2022 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import NIOExtras +import NIOCore +import NIOEmbedded +import NIOHTTP1 +import XCTest + +class HTTP1ProxyConnectHandlerTests: XCTestCase { + func testProxyConnectWithoutAuthorizationSuccess() throws { + let embedded = EmbeddedChannel() + defer { XCTAssertNoThrow(try embedded.finish(acceptAlreadyClosed: false)) } + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: promise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + let head = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertNil(head.headers["proxy-authorization"].first) + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertNoThrow(try promise.futureResult.wait()) + } + + func testProxyConnectWithAuthorization() throws { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: ["proxy-authorization" : "Basic abc123"], + deadline: .now() + .seconds(10), + promise: promise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + let head = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertEqual(head.headers["proxy-authorization"].first, "Basic abc123") + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertNoThrow(try promise.futureResult.wait()) + } + + func testProxyConnectWithoutAuthorizationFailure500() throws { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: promise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + let head = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertNil(head.headers["proxy-authorization"].first) + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .internalServerError) + // answering with 500 should lead to a triggered error in pipeline + XCTAssertThrowsError(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponseHead(responseHead)) + } + XCTAssertFalse(embedded.isActive, "Channel should be closed in response to the error") + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try promise.futureResult.wait()) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponseHead(responseHead)) + } + } + + func testProxyConnectWithoutAuthorizationButAuthorizationNeeded() throws { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: promise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + let head = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertNil(head.headers["proxy-authorization"].first) + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .proxyAuthenticationRequired) + // answering with 500 should lead to a triggered error in pipeline + XCTAssertThrowsError(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .proxyAuthenticationRequired()) + } + XCTAssertFalse(embedded.isActive, "Channel should be closed in response to the error") + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try promise.futureResult.wait()) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .proxyAuthenticationRequired()) + } + } + + func testProxyConnectReceivesBody() { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: promise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + var maybeHead: HTTPClientRequestPart? + XCTAssertNoThrow(maybeHead = try embedded.readOutbound(as: HTTPClientRequestPart.self)) + guard case .some(.head(let head)) = maybeHead else { + return XCTFail("Expected the proxy connect handler to first send a http head part") + } + + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) + // answering with a body should lead to a triggered error in pipeline + XCTAssertThrowsError(try embedded.writeInbound(HTTPClientResponsePart.body(ByteBuffer(bytes: [0, 1, 2, 3])))) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponse()) + } + XCTAssertEqual(embedded.isActive, false) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try promise.futureResult.wait()) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponse()) + } + } + + func testProxyConnectWithoutAuthorizationBufferedWrites() throws { + let embedded = EmbeddedChannel() + defer { XCTAssertNoThrow(try embedded.finish(acceptAlreadyClosed: false)) } + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let proxyConnectPromise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: proxyConnectPromise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + // write a request to be buffered inside the ProxyConnectHandler + // it will be unbuffered when the handler completes and removes itself + let requestHead = HTTPRequestHead(version: HTTPVersion(major: 1, minor: 1), method: .GET, uri: "http://apple.com") + var promises: [EventLoopPromise] = [] + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.head(requestHead)), promise: promises.last) + + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.body(.byteBuffer(ByteBuffer(string: "Test")))), promise: promises.last) + + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.end(nil)), promise: promises.last) + embedded.pipeline.flush() + + // read the connect header back + let connectHead = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + + XCTAssertEqual(connectHead.method, .CONNECT) + XCTAssertEqual(connectHead.uri, "swift.org:443") + XCTAssertNil(connectHead.headers["proxy-authorization"].first) + + let connectTrailers = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertEnd() + XCTAssertNil(connectTrailers) + + // ensure that nothing has been unbuffered by mistake + XCTAssertNil(try embedded.readOutbound(as: HTTPClientRequestPart.self)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertNoThrow(try proxyConnectPromise.futureResult.wait()) + + // read the buffered write back + let bufferedHead = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + + XCTAssertEqual(bufferedHead.method, .GET) + XCTAssertEqual(bufferedHead.uri, "http://apple.com") + XCTAssertNil(bufferedHead.headers["proxy-authorization"].first) + + let bufferedBody = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertBody() + XCTAssertEqual(bufferedBody, ByteBuffer(string: "Test")) + + let bufferedTrailers = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertEnd() + XCTAssertNil(bufferedTrailers) + + let resultFutures = promises.map { $0.futureResult } + XCTAssertNoThrow(_ = try EventLoopFuture.whenAllComplete(resultFutures, on: embedded.eventLoop).wait()) + } + + func testProxyConnectFailsBufferedWritesAreFailed() throws { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let proxyConnectPromise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: proxyConnectPromise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + // write a request to be buffered inside the ProxyConnectHandler + // it will be unbuffered when the handler completes and removes itself + let requestHead = HTTPRequestHead(version: HTTPVersion(major: 1, minor: 1), method: .GET, uri: "apple.com") + var promises: [EventLoopPromise] = [] + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.head(requestHead)), promise: promises.last) + + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.body(.byteBuffer(ByteBuffer(string: "Test")))), promise: promises.last) + + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.end(nil)), promise: promises.last) + embedded.pipeline.flush() + + // read the connect header back + let connectHead = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + + XCTAssertEqual(connectHead.method, .CONNECT) + XCTAssertEqual(connectHead.uri, "swift.org:443") + XCTAssertNil(connectHead.headers["proxy-authorization"].first) + + let connectTrailers = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertEnd() + XCTAssertNil(connectTrailers) + + // ensure that nothing has been unbuffered by mistake + XCTAssertNil(try embedded.readOutbound(as: HTTPClientRequestPart.self)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .internalServerError) + XCTAssertThrowsError(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponseHead(responseHead)) + } + XCTAssertFalse(embedded.isActive, "Channel should be closed in response to the error") + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try proxyConnectPromise.futureResult.wait()) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponseHead(responseHead)) + } + + // buffered writes are dropped + XCTAssertNil(try embedded.readOutbound(as: HTTPClientRequestPart.self)) + + // all outstanding buffered write promises should be completed + let resultFutures = promises.map { $0.futureResult } + XCTAssertNoThrow(_ = try EventLoopFuture.whenAllComplete(resultFutures, on: embedded.eventLoop).wait()) + } +} + +struct HTTPRequestPartMismatch: Error {} + +extension HTTPClientRequestPart { + @discardableResult + func assertHead(file: StaticString = #file, line: UInt = #line) throws -> HTTPRequestHead { + switch self { + case .head(let head): + return head + default: + XCTFail("Expected .head but got \(self)", file: file, line: line) + throw HTTPRequestPartMismatch() + } + } + + @discardableResult + func assertBody(file: StaticString = #file, line: UInt = #line) throws -> ByteBuffer { + switch self { + case .body(.byteBuffer(let body)): + return body + default: + XCTFail("Expected .body but got \(self)", file: file, line: line) + throw HTTPRequestPartMismatch() + } + } + + @discardableResult + func assertEnd(file: StaticString = #file, line: UInt = #line) throws -> HTTPHeaders? { + switch self { + case .end(let trailers): + return trailers + default: + XCTFail("Expected .end but got \(self)", file: file, line: line) + throw HTTPRequestPartMismatch() + } + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 62c76d82..2d3e61c7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -17,19 +17,9 @@ RUN apt-get update && apt-get install -y wget RUN apt-get update && apt-get install -y lsof dnsutils netcat-openbsd net-tools curl jq # used by integration tests RUN apt-get update && apt-get install -y zlib1g-dev -# ruby and jazzy for docs generation +# ruby for soundness RUN apt-get update && apt-get install -y ruby ruby-dev libsqlite3-dev build-essential -# jazzy no longer works on xenial as ruby is too old. -RUN if [ "${ubuntu_version}" = "focal" ] ; then echo "gem: --no-document" > ~/.gemrc ; fi -RUN if [ "${ubuntu_version}" = "focal" ] ; then gem install jazzy ; fi # tools RUN mkdir -p $HOME/.tools RUN echo 'export PATH="$HOME/.tools:$PATH"' >> $HOME/.profile - -# swiftformat (until part of the toolchain) - -ARG swiftformat_version=0.40.12 -RUN git clone --branch $swiftformat_version --depth 1 https://github.com/nicklockwood/SwiftFormat $HOME/.tools/swift-format -RUN cd $HOME/.tools/swift-format && swift build -c release -RUN ln -s $HOME/.tools/swift-format/.build/release/swiftformat $HOME/.tools/swiftformat diff --git a/docker/docker-compose.2004.main.yaml b/docker/docker-compose.2204.58.yaml similarity index 50% rename from docker/docker-compose.2004.main.yaml rename to docker/docker-compose.2204.58.yaml index 6e9fe8c6..a7804722 100644 --- a/docker/docker-compose.2004.main.yaml +++ b/docker/docker-compose.2204.58.yaml @@ -3,15 +3,15 @@ version: "3" services: runtime-setup: - image: swift-nio-extras:20.04-main + image: swift-nio-extras:22.04-5.8 build: args: - base_image: "swiftlang/swift:nightly-main-focal" + base_image: "swiftlang/swift:nightly-main-jammy" test: - image: swift-nio-extras:20.04-main + image: swift-nio-extras:22.04-5.8 environment: - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error shell: - image: swift-nio-extras:20.04-main + image: swift-nio-extras:22.04-5.8 diff --git a/docker/docker-compose.2204.main.yaml b/docker/docker-compose.2204.main.yaml new file mode 100644 index 00000000..ee845e1c --- /dev/null +++ b/docker/docker-compose.2204.main.yaml @@ -0,0 +1,17 @@ +version: "3" + +services: + + runtime-setup: + image: swift-nio-extras:22.04-main + build: + args: + base_image: "swiftlang/swift:nightly-main-jammy" + + test: + image: swift-nio-extras:22.04-main + environment: + - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error + + shell: + image: swift-nio-extras:22.04-main diff --git a/scripts/generate_docs.sh b/scripts/generate_docs.sh deleted file mode 100755 index adff6ab0..00000000 --- a/scripts/generate_docs.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## This source file is part of the SwiftNIO open source project -## -## Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors -## Licensed under Apache License v2.0 -## -## See LICENSE.txt for license information -## See CONTRIBUTORS.txt for the list of SwiftNIO project authors -## -## SPDX-License-Identifier: Apache-2.0 -## -##===----------------------------------------------------------------------===## - -set -e - -my_path="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -root_path="$my_path/.." -version=$(git describe --abbrev=0 --tags || echo "0.0.0") -modules=(NIOExtras NIOHTTPCompression NIOSOCKS) - -if [[ "$(uname -s)" == "Linux" ]]; then - # build code if required - if [[ ! -d "$root_path/.build/x86_64-unknown-linux" ]]; then - swift build - fi - # setup source-kitten if required - mkdir -p "$root_path/.build/sourcekitten" - source_kitten_source_path="$root_path/.build/sourcekitten/source" - if [[ ! -d "$source_kitten_source_path" ]]; then - git clone https://github.com/jpsim/SourceKitten.git "$source_kitten_source_path" - fi - source_kitten_path="$source_kitten_source_path/.build/debug" - if [[ ! -d "$source_kitten_path" ]]; then - rm -rf "$source_kitten_source_path/.swift-version" - cd "$source_kitten_source_path" && swift build && cd "$root_path" - fi - # generate - for module in "${modules[@]}"; do - if [[ ! -f "$root_path/.build/sourcekitten/$module.json" ]]; then - "$source_kitten_path/sourcekitten" doc --spm --module-name $module > "$root_path/.build/sourcekitten/$module.json" - fi - done -fi - -[[ -d docs/$version ]] || mkdir -p docs/$version -[[ -d swift-nio-extras.xcodeproj ]] || swift package generate-xcodeproj - -# run jazzy -if ! command -v jazzy > /dev/null; then - gem install jazzy --no-ri --no-rdoc -fi - -jazzy_dir="$root_path/.build/jazzy" -rm -rf "$jazzy_dir" -mkdir -p "$jazzy_dir" - -module_switcher="$jazzy_dir/README.md" -jazzy_args=(--clean - --author 'swift-nio team' - --readme "$module_switcher" - --author_url https://github.com/apple/swift-nio-extras - --github_url https://github.com/apple/swift-nio-extras - --theme fullwidth - --xcodebuild-arguments -scheme,swift-nio-extras-Package) -cat > "$module_switcher" <<"EOF" -# swift-nio-extras Docs - -swift-nio-extras is a good place for code that is related to NIO but not core. -It can also be used to incubate APIs for tasks that are possible with core-NIO but are cumbersome today. - -swift-nio-extras contains multiple modules: - ---- - -For the API documentation of the other repositories in the SwiftNIO family check: - -- [`swift-nio` API docs](https://apple.github.io/swift-nio) -- [`swift-nio-ssl` API docs](https://apple.github.io/swift-nio-ssl) -- [`swift-nio-http2` API docs](https://apple.github.io/swift-nio-http2) -- [`swift-nio-extras` API docs](https://apple.github.io/swift-nio-extras/docs/current/NIOExtras/index.html) - -EOF - -for module in "${modules[@]}"; do - args=("${jazzy_args[@]}" --output "$jazzy_dir/docs/$version/$module" --docset-path "$jazzy_dir/docset/$version/$module" - --module "$module" --module-version $version - --root-url "https://apple.github.io/swift-nio-extras/docs/$version/$module/") - if [[ -f "$root_path/.build/sourcekitten/$module.json" ]]; then - args+=(--sourcekitten-sourcefile "$root_path/.build/sourcekitten/$module.json") - fi - jazzy "${args[@]}" -done - -# push to github pages -if [[ $PUSH == true ]]; then - BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) - GIT_AUTHOR=$(git --no-pager show -s --format='%an <%ae>' HEAD) - git fetch origin +gh-pages:gh-pages - git checkout gh-pages - rm -rf "docs/$version" - rm -rf "docs/current" - cp -r "$jazzy_dir/docs/$version" docs/ - cp -r "docs/$version" docs/current - git add --all docs - echo '' > index.html - git add index.html - touch .nojekyll - git add .nojekyll - changes=$(git diff-index --name-only HEAD) - if [[ -n "$changes" ]]; then - echo -e "changes detected\n$changes" - git commit --author="$GIT_AUTHOR" -m "publish $version docs" - git push origin gh-pages - else - echo "no changes detected" - fi - git checkout -f $BRANCH_NAME -fi diff --git a/scripts/soundness.sh b/scripts/soundness.sh index 574d3bac..59dc5ea5 100755 --- a/scripts/soundness.sh +++ b/scripts/soundness.sh @@ -18,7 +18,7 @@ here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" function replace_acceptable_years() { # this needs to replace all acceptable forms with 'YEARS' - sed -e 's/20[12][789012]-20[12][789012]/YEARS/' -e 's/20[12][89012]/YEARS/' + sed -e 's/20[12][7890123]-20[12][7890123]/YEARS/' -e 's/20[12][890123]/YEARS/' } printf "=> Checking linux tests... "