Skip to content

Commit c2355f6

Browse files
committed
fix UDS without a baseURL
Previously, UNIX Domain Sockets would only work if the URL also had a "base URL". If it didn't have a base URL, it would try to connect to the empty string which would fail :). Now, we support both cases: - URLs with a baseURL (the path to the UDS) and a path (actual path) - URLs that just have an actual path (path to the UDS) where we'll just use "/" as the URL's path
1 parent e2636a4 commit c2355f6

File tree

5 files changed

+189
-10
lines changed

5 files changed

+189
-10
lines changed

Sources/AsyncHTTPClient/HTTPClient.swift

+10-4
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,11 @@ public class HTTPClient {
272272
return channel.eventLoop.makeSucceededFuture(())
273273
}
274274
}.flatMap {
275-
let taskHandler = TaskHandler(task: task, delegate: delegate, redirectHandler: redirectHandler, ignoreUncleanSSLShutdown: self.configuration.ignoreUncleanSSLShutdown)
275+
let taskHandler = TaskHandler(task: task,
276+
kind: request.kind,
277+
delegate: delegate,
278+
redirectHandler: redirectHandler,
279+
ignoreUncleanSSLShutdown: self.configuration.ignoreUncleanSSLShutdown)
276280
return channel.pipeline.addHandler(taskHandler)
277281
}
278282
}
@@ -282,9 +286,11 @@ public class HTTPClient {
282286
}
283287

284288
let eventLoopChannel: EventLoopFuture<Channel>
285-
if request.kind == .unixSocket, let baseURL = request.url.baseURL {
286-
eventLoopChannel = bootstrap.connect(unixDomainSocketPath: baseURL.path)
287-
} else {
289+
switch request.kind {
290+
case .unixSocket:
291+
let socketPath = request.url.baseURL?.path ?? request.url.path
292+
eventLoopChannel = bootstrap.connect(unixDomainSocketPath: socketPath)
293+
case .host:
288294
let address = self.resolveAddress(request: request, proxy: self.configuration.proxy)
289295
eventLoopChannel = bootstrap.connect(host: address.host, port: address.port)
290296
}

Sources/AsyncHTTPClient/HTTPHandler.swift

+20-2
Original file line numberDiff line numberDiff line change
@@ -559,12 +559,18 @@ internal class TaskHandler<Delegate: HTTPClientResponseDelegate> {
559559
var state: State = .idle
560560
var pendingRead = false
561561
var mayRead = true
562+
let kind: HTTPClient.Request.Kind
562563

563-
init(task: HTTPClient.Task<Delegate.Response>, delegate: Delegate, redirectHandler: RedirectHandler<Delegate.Response>?, ignoreUncleanSSLShutdown: Bool) {
564+
init(task: HTTPClient.Task<Delegate.Response>,
565+
kind: HTTPClient.Request.Kind,
566+
delegate: Delegate,
567+
redirectHandler: RedirectHandler<Delegate.Response>?,
568+
ignoreUncleanSSLShutdown: Bool) {
564569
self.task = task
565570
self.delegate = delegate
566571
self.redirectHandler = redirectHandler
567572
self.ignoreUncleanSSLShutdown = ignoreUncleanSSLShutdown
573+
self.kind = kind
568574
}
569575
}
570576

@@ -653,7 +659,19 @@ extension TaskHandler: ChannelDuplexHandler {
653659
self.state = .idle
654660
let request = unwrapOutboundIn(data)
655661

656-
var head = HTTPRequestHead(version: HTTPVersion(major: 1, minor: 1), method: request.method, uri: request.url.uri)
662+
let uri: String
663+
switch (self.kind, request.url.baseURL) {
664+
case (.host, _):
665+
uri = request.url.uri
666+
case (.unixSocket, .none):
667+
uri = "/" // we don't have a real path, the path we have is the path of the UNIX Domain Socket.
668+
case (.unixSocket, .some(_)):
669+
uri = request.url.uri
670+
}
671+
672+
var head = HTTPRequestHead(version: HTTPVersion(major: 1, minor: 1),
673+
method: request.method,
674+
uri: uri)
657675
var headers = request.headers
658676

659677
if !request.headers.contains(name: "Host") {

Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift

+11-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ class HTTPClientInternalTests: XCTestCase {
2828
let task = Task<Void>(eventLoop: channel.eventLoop)
2929

3030
try channel.pipeline.addHandler(recorder).wait()
31-
try channel.pipeline.addHandler(TaskHandler(task: task, delegate: TestHTTPDelegate(), redirectHandler: nil, ignoreUncleanSSLShutdown: false)).wait()
31+
try channel.pipeline.addHandler(TaskHandler(task: task,
32+
kind: .host,
33+
delegate: TestHTTPDelegate(),
34+
redirectHandler: nil,
35+
ignoreUncleanSSLShutdown: false)).wait()
3236

3337
var request = try Request(url: "http://localhost/get")
3438
request.headers.add(name: "X-Test-Header", value: "X-Test-Value")
@@ -56,6 +60,7 @@ class HTTPClientInternalTests: XCTestCase {
5660

5761
XCTAssertNoThrow(try channel.pipeline.addHandler(recorder).wait())
5862
XCTAssertNoThrow(try channel.pipeline.addHandler(TaskHandler(task: task,
63+
kind: .host,
5964
delegate: TestHTTPDelegate(),
6065
redirectHandler: nil,
6166
ignoreUncleanSSLShutdown: false)).wait())
@@ -74,7 +79,11 @@ class HTTPClientInternalTests: XCTestCase {
7479
let channel = EmbeddedChannel()
7580
let delegate = TestHTTPDelegate()
7681
let task = Task<Void>(eventLoop: channel.eventLoop)
77-
let handler = TaskHandler(task: task, delegate: delegate, redirectHandler: nil, ignoreUncleanSSLShutdown: false)
82+
let handler = TaskHandler(task: task,
83+
kind: .host,
84+
delegate: delegate,
85+
redirectHandler: nil,
86+
ignoreUncleanSSLShutdown: false)
7887

7988
try channel.pipeline.addHandler(handler).wait()
8089

Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift

+102-2
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,89 @@ internal final class RecordingHandler<Input, Output>: ChannelDuplexHandler {
9292
}
9393
}
9494

95+
enum TemporaryFileHelpers {
96+
private static var temporaryDirectory: String {
97+
get {
98+
#if targetEnvironment(simulator)
99+
// Simulator temp directories are so long (and contain the user name) that they're not usable
100+
// for UNIX Domain Socket paths (which are limited to 103 bytes).
101+
return "/tmp"
102+
#else
103+
#if os(Android)
104+
return "/data/local/tmp"
105+
#elseif os(Linux)
106+
return "/tmp"
107+
#else
108+
if #available(macOS 10.12, iOS 10, tvOS 10, watchOS 3, *) {
109+
return FileManager.default.temporaryDirectory.path
110+
} else {
111+
return "/tmp"
112+
}
113+
#endif // os
114+
#endif // targetEnvironment
115+
}
116+
}
117+
118+
private static func openTemporaryFile() -> (CInt, String) {
119+
let template = "\(temporaryDirectory)/ahc_XXXXXX"
120+
var templateBytes = template.utf8 + [0]
121+
let templateBytesCount = templateBytes.count
122+
let fd = templateBytes.withUnsafeMutableBufferPointer { ptr in
123+
ptr.baseAddress!.withMemoryRebound(to: Int8.self, capacity: templateBytesCount) { ptr in
124+
return mkstemp(ptr)
125+
}
126+
}
127+
templateBytes.removeLast()
128+
return (fd, String(decoding: templateBytes, as: Unicode.UTF8.self))
129+
}
130+
131+
/// This function creates a filename that can be used for a temporary UNIX domain socket path.
132+
///
133+
/// If the temporary directory is too long to store a UNIX domain socket path, it will `chdir` into the temporary
134+
/// directory and return a short-enough path. The iOS simulator is known to have too long paths.
135+
internal static func withTemporaryUnixDomainSocketPathName<T>(directory: String = temporaryDirectory,
136+
_ body: (String) throws -> T) throws -> T {
137+
// this is racy but we're trying to create the shortest possible path so we can't add a directory...
138+
let (fd, path) = openTemporaryFile()
139+
close(fd)
140+
try! FileManager.default.removeItem(atPath: path)
141+
142+
let saveCurrentDirectory = FileManager.default.currentDirectoryPath
143+
let restoreSavedCWD: Bool
144+
let shortEnoughPath: String
145+
do {
146+
_ = try SocketAddress(unixDomainSocketPath: path)
147+
// this seems to be short enough for a UDS
148+
shortEnoughPath = path
149+
restoreSavedCWD = false
150+
} catch SocketAddressError.unixDomainSocketPathTooLong {
151+
FileManager.default.changeCurrentDirectoryPath(URL(fileURLWithPath: path).deletingLastPathComponent().absoluteString)
152+
shortEnoughPath = URL(fileURLWithPath: path).lastPathComponent
153+
restoreSavedCWD = true
154+
print("WARNING: Path '\(path)' could not be used as UNIX domain socket path, using chdir & '\(shortEnoughPath)'")
155+
}
156+
defer {
157+
if FileManager.default.fileExists(atPath: path) {
158+
try? FileManager.default.removeItem(atPath: path)
159+
}
160+
if restoreSavedCWD {
161+
FileManager.default.changeCurrentDirectoryPath(saveCurrentDirectory)
162+
}
163+
}
164+
return try body(shortEnoughPath)
165+
}
166+
}
167+
95168
internal final class HTTPBin {
96169
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
97170
let serverChannel: Channel
98171
let isShutdown: NIOAtomic<Bool> = .makeAtomic(value: false)
99172

173+
enum BindTarget {
174+
case unixDomainSocket(String)
175+
case localhostIPv4RandomPort
176+
}
177+
100178
var port: Int {
101179
return Int(self.serverChannel.localAddress!.port!)
102180
}
@@ -112,7 +190,19 @@ internal final class HTTPBin {
112190
return channel.pipeline.addHandler(try! NIOSSLServerHandler(context: context), position: .first)
113191
}
114192

115-
init(ssl: Bool = false, compress: Bool = false, simulateProxy: HTTPProxySimulator.Option? = nil, channelPromise: EventLoopPromise<Channel>? = nil) {
193+
init(ssl: Bool = false,
194+
compress: Bool = false,
195+
bindTarget: BindTarget = .localhostIPv4RandomPort,
196+
simulateProxy: HTTPProxySimulator.Option? = nil,
197+
channelPromise: EventLoopPromise<Channel>? = nil) {
198+
199+
let socketAddress: SocketAddress
200+
switch bindTarget {
201+
case .localhostIPv4RandomPort:
202+
socketAddress = try! SocketAddress(ipAddress: "127.0.0.1", port: 0)
203+
case .unixDomainSocket(let path):
204+
socketAddress = try! SocketAddress.init(unixDomainSocketPath: path)
205+
}
116206
self.serverChannel = try! ServerBootstrap(group: self.group)
117207
.serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
118208
.childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1)
@@ -145,7 +235,7 @@ internal final class HTTPBin {
145235
}
146236
}
147237
}
148-
.bind(host: "127.0.0.1", port: 0).wait()
238+
.bind(to: socketAddress).wait()
149239
}
150240

151241
func shutdown() throws {
@@ -250,6 +340,16 @@ internal final class HttpBinHandler: ChannelInboundHandler {
250340
case .head(let req):
251341
let url = URL(string: req.uri)!
252342
switch url.path {
343+
case "/":
344+
var headers = HTTPHeaders()
345+
headers.add(name: "X-Is-This-Slash", value: "Yes")
346+
self.resps.append(HTTPResponseBuilder(status: .ok, headers: headers))
347+
return
348+
case "/echo-uri":
349+
var headers = HTTPHeaders()
350+
headers.add(name: "X-Calling-URI", value: req.uri)
351+
self.resps.append(HTTPResponseBuilder(status: .ok, headers: headers))
352+
return
253353
case "/ok":
254354
self.resps.append(HTTPResponseBuilder(status: .ok))
255355
return

Tests/AsyncHTTPClientTests/HTTPClientTests.swift

+46
Original file line numberDiff line numberDiff line change
@@ -1003,4 +1003,50 @@ class HTTPClientTests: XCTestCase {
10031003
XCTAssertNil(response?.body)
10041004
}
10051005
}
1006+
1007+
func testUDSBasic() {
1008+
// This tests just connecting to a URL where the whole URL is the UNIX domain socket path like
1009+
// unix:///this/is/my/socket.sock
1010+
// We don't really have a path component, so we'll have to use "/"
1011+
XCTAssertNoThrow(try TemporaryFileHelpers.withTemporaryUnixDomainSocketPathName { path in
1012+
let httpBin = HTTPBin(bindTarget: .unixDomainSocket(path))
1013+
defer {
1014+
XCTAssertNoThrow(try httpBin.shutdown())
1015+
}
1016+
let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
1017+
defer {
1018+
XCTAssertNoThrow(try httpClient.syncShutdown())
1019+
}
1020+
1021+
let target = "unix://\(path)"
1022+
XCTAssertNoThrow(XCTAssertEqual(["Yes"[...]],
1023+
try httpClient.get(url: target).wait().headers[canonicalForm: "X-Is-This-Slash"]))
1024+
})
1025+
}
1026+
1027+
func testUDSSocketAndPath() {
1028+
// Here, we're testing a URL that's encoding two different paths:
1029+
//
1030+
// 1. a "base path" which is the path to the UNIX domain socket
1031+
// 2. an actual path which is the normal path in a regular URL like https://example.com/this/is/the/path
1032+
XCTAssertNoThrow(try TemporaryFileHelpers.withTemporaryUnixDomainSocketPathName { path in
1033+
let httpBin = HTTPBin(bindTarget: .unixDomainSocket(path))
1034+
defer {
1035+
XCTAssertNoThrow(try httpBin.shutdown())
1036+
}
1037+
let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
1038+
defer {
1039+
XCTAssertNoThrow(try httpClient.syncShutdown())
1040+
}
1041+
1042+
guard let target = URL(string: "/echo-uri", relativeTo: URL(string: "unix://\(path)")),
1043+
let request = try? Request(url: target) else {
1044+
XCTFail("couldn't build URL for request")
1045+
return
1046+
}
1047+
XCTAssertNoThrow(XCTAssertEqual(["/echo-uri"[...]],
1048+
try httpClient.execute(request: request).wait().headers[canonicalForm: "X-Calling-URI"]))
1049+
})
1050+
}
1051+
10061052
}

0 commit comments

Comments
 (0)