diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d726568..b719a26 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,10 +6,10 @@ on: workflow_dispatch: jobs: - xcode_26_2: + xcode_26_6: runs-on: macos-26 env: - DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 @@ -39,10 +39,10 @@ jobs: junit: result-swift-testing.xml coverage: .build/debug/codecov/FlyingFox.json - xcode_26_1: - runs-on: macos-15 + xcode_26_2: + runs-on: macos-26 env: - DEVELOPER_DIR: /Applications/Xcode_26.1.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 @@ -125,7 +125,7 @@ jobs: linux_swift_6_3: runs-on: ubuntu-latest - container: swiftlang/swift:nightly-6.3-noble + container: swift:6.3 timeout-minutes: 5 steps: - name: Checkout @@ -137,9 +137,9 @@ jobs: - name: Test run: swift test --skip-build - linux_swift_6_1_musl: + linux_swift_6_3_musl: runs-on: ubuntu-latest - container: swift:6.1.2 + container: swift:6.3.3 steps: - name: Checkout uses: actions/checkout@v4 @@ -148,7 +148,7 @@ jobs: - name: SDK List Pre run: swift sdk list - name: Install SDK - run: swift sdk install https://download.swift.org/swift-6.1.2-release/static-sdk/swift-6.1.2-RELEASE/swift-6.1.2-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum df0b40b9b582598e7e3d70c82ab503fd6fbfdff71fd17e7f1ab37115a0665b3b + run: swift sdk install https://download.swift.org/swift-6.3.3-release/static-sdk/swift-6.3.3-RELEASE/swift-6.3.3-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz --checksum 87c3eaf908e67c0e13a84367119e12273cec1d2cd3d81f7d74bb36722d6b607b - name: SDK List Post run: swift sdk list - name: Build diff --git a/FlyingFox.podspec.json b/FlyingFox.podspec.json index d901d31..0e64acd 100644 --- a/FlyingFox.podspec.json +++ b/FlyingFox.podspec.json @@ -1,6 +1,6 @@ { "name": "FlyingFox", - "version": "0.26.2", + "version": "0.27.1", "summary": "Lightweight, HTTP server written in Swift using async/await", "homepage": "https://github.com/swhitty/FlyingFox", "authors": "Simon Whitty", @@ -10,7 +10,7 @@ }, "source": { "git": "https://github.com/swhitty/FlyingFox.git", - "tag": "0.26.2" + "tag": "0.27.1" }, "platforms": { "ios": "13.0", @@ -20,7 +20,7 @@ }, "source_files": "FlyingFox/Sources/**/*.swift", "dependencies": { - "FlyingSocks": "~> 0.26.2" + "FlyingSocks": "~> 0.27.1" }, "pod_target_xcconfig": { "OTHER_SWIFT_FLAGS": "-package-name FlyingFox" diff --git a/FlyingFox/Sources/HTTPBodySequence.swift b/FlyingFox/Sources/HTTPBodySequence.swift index 77de35d..ec73715 100644 --- a/FlyingFox/Sources/HTTPBodySequence.swift +++ b/FlyingFox/Sources/HTTPBodySequence.swift @@ -69,6 +69,14 @@ public struct HTTPBodySequence: Sendable, AsyncSequence { ) } + init(chunked bytes: some AsyncBufferedSequence, suggestedBufferSize: Int = 4096) { + self.storage = Storage( + bytes: HTTPChunkedTransferDecoder(bytes: bytes), + count: nil, + bufferSize: suggestedBufferSize + ) + } + public init(file url: URL, range: Range? = nil, suggestedBufferSize: Int = 4096) throws { self.storage = try Storage( fileURL: url, diff --git a/FlyingFox/Sources/HTTPCacheControl.swift b/FlyingFox/Sources/HTTPCacheControl.swift index 9f1f727..9b61d6e 100644 --- a/FlyingFox/Sources/HTTPCacheControl.swift +++ b/FlyingFox/Sources/HTTPCacheControl.swift @@ -6,9 +6,6 @@ // import Foundation -#if canImport(CryptoKit) -import CryptoKit -#endif public enum HTTPCacheControl { public enum ResponseDirective: Sendable, CustomStringConvertible { @@ -58,18 +55,6 @@ public enum HTTPCacheControl { } } - static func getDateHeaderValue() -> String { - return Self.dateFormatter.string(from: Date()) - } - - private static let dateFormatter: DateFormatter = { - let df = DateFormatter() - df.dateFormat = "EEE, d MMM yyyy HH:mm:ss zzz" - df.timeZone = TimeZone(secondsFromGMT: 0) - df.locale = Locale(identifier: "en_US_POSIX") - return df - }() - static func getExpiresValue(for filePath: URL) -> String? { do { let path = { @@ -81,21 +66,32 @@ public enum HTTPCacheControl { }() let attributes = try FileManager.default.attributesOfItem(atPath: path) if let modificationDate = attributes[FileAttributeKey.modificationDate] as? Date ?? attributes[FileAttributeKey.creationDate] as? Date { - return Self.dateFormatter.string(from: modificationDate) + return HTTPDate.string(from: modificationDate) } } catch { } return nil } - static func getETagValue(for data: Data) -> String? { -#if canImport(CryptoKit) - let sha256digest = SHA256.hash(data: data) - let eTag = "\"\(sha256digest.map { String(format: "%02x", $0) }.joined())\"" - return eTag -#else - return nil -#endif + // Strong ETag derived from (mtime, size), matching the format used by + // nginx (`"%xT-%xO"`, see ngx_http_set_etag in src/http/ngx_http_core_module.c) + // and Apache HTTPD's default `FileETag MTime Size`. Cheap to compute and + // does not require reading file contents. + static func getETagValue(for filePath: URL) -> String? { + let path = { + if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { + return filePath.path() + } else { + return filePath.path + } + }() + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let modificationDate = attributes[.modificationDate] as? Date, + let size = attributes[.size] as? UInt64 else { + return nil + } + let mtime = Int64(modificationDate.timeIntervalSince1970) + return String(format: "\"%llx-%llx\"", mtime, size) } } diff --git a/FlyingFox/Sources/HTTPChunkedDecodedSequence.swift b/FlyingFox/Sources/HTTPChunkedDecodedSequence.swift new file mode 100644 index 0000000..de8f073 --- /dev/null +++ b/FlyingFox/Sources/HTTPChunkedDecodedSequence.swift @@ -0,0 +1,132 @@ +// +// HTTPChunkedDecodedSequence.swift +// FlyingFox +// +// Created by Simon Whitty on 24/04/2026. +// Copyright © 2026 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import FlyingSocks + +// Decodes an `AsyncBufferedSequence` of bytes that are framed using the +// `chunked` transfer coding (RFC 9112 §7.1) and yields the decoded payload. +struct HTTPChunkedTransferDecoder: AsyncBufferedSequence, Sendable + where Base: AsyncBufferedSequence, + Base.Element == UInt8, + Base: Sendable { + typealias Element = UInt8 + + private let bytes: Base + + init(bytes: Base) { + self.bytes = bytes + } + + func makeAsyncIterator() -> Iterator { + Iterator(bytes: bytes.makeAsyncIterator()) + } +} + +extension HTTPChunkedTransferDecoder { + + struct Iterator: AsyncBufferedIteratorProtocol { + + private var bytes: Base.AsyncIterator + private var remainingInChunk: Int = 0 + private var isComplete: Bool = false + + init(bytes: Base.AsyncIterator) { + self.bytes = bytes + } + + mutating func next() async throws -> UInt8? { + fatalError("call nextBuffer(suggested:)") + } + + mutating func nextBuffer(suggested count: Int) async throws -> [UInt8]? { + guard !isComplete else { return nil } + + if remainingInChunk == 0 { + let size = try await readChunkSize() + if size == 0 { + try await consumeTrailer() + isComplete = true + return nil + } + remainingInChunk = size + } + + let take = Swift.min(count, remainingInChunk) + guard let buffer = try await bytes.nextBuffer(count: take) else { + throw HTTPDecoder.Error("Unexpected end of chunked body") + } + remainingInChunk -= buffer.count + if remainingInChunk == 0 { + try await consumeCRLF() + } + return buffer + } + + // RFC 9112 §7.1: chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF + // chunk-size is hexadecimal; chunk-ext (preceded by `;`) is ignored here. + private mutating func readChunkSize() async throws -> Int { + let line = try await readLine() + let sizePart = line.split(separator: ";", maxSplits: 1).first.map(String.init) ?? line + guard let size = Int(sizePart, radix: 16), size >= 0 else { + throw HTTPDecoder.Error("Invalid chunk-size: \(line)") + } + return size + } + + // RFC 9112 §7.1.2: trailer-section is zero or more field lines terminated by CRLF. + private mutating func consumeTrailer() async throws { + while !(try await readLine()).isEmpty { } + } + + private mutating func consumeCRLF() async throws { + guard let buffer = try await bytes.nextBuffer(count: 2), + buffer == [0x0D, 0x0A] else { + throw HTTPDecoder.Error("Expected CRLF after chunk-data") + } + } + + private mutating func readLine() async throws -> String { + var line = [UInt8]() + while true { + guard let buffer = try await bytes.nextBuffer(count: 1) else { + throw HTTPDecoder.Error("Unexpected end of chunked body") + } + let byte = buffer[0] + if byte == 0x0A { + if line.last == 0x0D { line.removeLast() } + return String(decoding: line, as: UTF8.self) + } + line.append(byte) + } + } + } +} diff --git a/FlyingFox/Sources/HTTPClient.swift b/FlyingFox/Sources/HTTPClient.swift index 7895241..720851d 100644 --- a/FlyingFox/Sources/HTTPClient.swift +++ b/FlyingFox/Sources/HTTPClient.swift @@ -31,19 +31,28 @@ import FlyingSocks -@_spi(Private) -public struct _HTTPClient { +@available(*, deprecated, renamed: "HTTPClient") +public typealias _HTTPClient = HTTPClient + +public struct HTTPClient: ~Copyable { + + private var _lastSocket: AsyncSocket? public init() { } - public func sendHTTPRequest(_ request: HTTPRequest, to address: some SocketAddress) async throws -> HTTPResponse { + public mutating func sendHTTPRequest(_ request: HTTPRequest, to address: some SocketAddress) async throws -> HTTPResponse { + + try? _lastSocket?.close() + _lastSocket = nil + let socket = try await AsyncSocket.connected(to: address) + _lastSocket = socket try await socket.writeRequest(request) - let response = try await socket.readResponse() - // if streaming very large responses then you shouldn't close here - // maybe better to close in deinit instead - try? socket.close() - return response + return try await socket.readResponse() + } + + deinit { + try? _lastSocket?.close() } } diff --git a/FlyingFox/Sources/HTTPConnection.swift b/FlyingFox/Sources/HTTPConnection.swift index 8d42a0a..59c93e3 100644 --- a/FlyingFox/Sources/HTTPConnection.swift +++ b/FlyingFox/Sources/HTTPConnection.swift @@ -36,18 +36,26 @@ struct HTTPConnection: Sendable { let hostname: String private let socket: AsyncSocket + private let bytes: AsyncBufferingSequence private let decoder: HTTPDecoder private let logger: any Logging - let requests: HTTPRequestSequence + let requests: HTTPRequestSequence> init(socket: AsyncSocket, decoder: HTTPDecoder, logger: some Logging) { self.socket = socket self.decoder = decoder self.logger = logger + // Wrap socket.bytes once per connection so header parsing, body + // reading, and any subsequent protocol upgrade all share a single + // 4 KB read buffer. Without this, the HTTP decoder pulls one byte + // per syscall through `iterator.next()` while parsing the status + // line and headers. + let bytes = AsyncBufferingSequence(socket.bytes) let (peer, identifier) = HTTPConnection.makeIdentifier(from: socket.socket) self.hostname = identifier - self.requests = HTTPRequestSequence(bytes: socket.bytes, decoder: decoder, remoteAddress: peer) + self.bytes = bytes + self.requests = HTTPRequestSequence(bytes: bytes, decoder: decoder, remoteAddress: peer) } func complete() async { @@ -55,6 +63,12 @@ struct HTTPConnection: Sendable { } func sendResponse(_ response: HTTPResponse) async throws { + var response = response + // RFC 9110 §6.6.1: an origin server with a clock MUST send Date on + // 2xx/3xx/4xx responses and MAY on 1xx/5xx; handler-supplied values win. + if response.headers[.date] == nil { + response.headers[.date] = HTTPDate.string(from: Date()) + } let header = HTTPEncoder.encodeResponseHeader(response) switch response.payload { @@ -76,7 +90,9 @@ struct HTTPConnection: Sendable { } func switchToWebSocket(with handler: some WSHandler, response: Data) async throws { - let client = AsyncThrowingStream.decodingFrames(from: socket.bytes) + // Reuse the connection-wide buffered stream so any bytes already + // pulled past the upgrade request remain available to the WS framer. + let client = AsyncThrowingStream.decodingFrames(from: bytes) let server = try await handler.makeFrames(for: client) try await socket.write(response) logger.logSwitchProtocol(self, to: "websocket") diff --git a/FlyingFox/Sources/HTTPDate.swift b/FlyingFox/Sources/HTTPDate.swift new file mode 100644 index 0000000..fe845d7 --- /dev/null +++ b/FlyingFox/Sources/HTTPDate.swift @@ -0,0 +1,28 @@ +// +// HTTPDate.swift +// FlyingFox +// +// Created by Ian Gordon on 22.07.26. +// + +import Foundation + +// Single source of truth for HTTP date formatting: IMF-fixdate per +// RFC 9110 §5.6.7, e.g. "Sun, 06 Nov 1994 08:49:37 GMT". The day of +// month is always two digits, the zone is the literal "GMT", and +// en_US_POSIX pins the English day/month names regardless of the +// system locale. +enum HTTPDate { + + static func string(from date: Date) -> String { + formatter.string(from: date) + } + + private static let formatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + df.timeZone = TimeZone(secondsFromGMT: 0) + df.locale = Locale(identifier: "en_US_POSIX") + return df + }() +} diff --git a/FlyingFox/Sources/HTTPDecoder.swift b/FlyingFox/Sources/HTTPDecoder.swift index 0a0c531..0576861 100644 --- a/FlyingFox/Sources/HTTPDecoder.swift +++ b/FlyingFox/Sources/HTTPDecoder.swift @@ -50,7 +50,11 @@ struct HTTPDecoder { let version = HTTPVersion(String(comps[2])) let target = makeTarget(from: comps[1]) let headers = try await readHeaders(from: bytes) - let body = try await readBody(from: bytes, length: headers[.contentLength]) + let body = try await readBody( + from: bytes, + contentLength: headers[.contentLength], + transferEncoding: headers[.transferEncoding] + ) return HTTPRequest( method: method, @@ -74,7 +78,11 @@ struct HTTPDecoder { let statusCode = HTTPStatusCode(code, phrase: String(comps[2])) let headers = try await readHeaders(from: bytes) - let body = try await readBody(from: bytes, length: headers[.contentLength]) + let body = try await readBody( + from: bytes, + contentLength: headers[.contentLength], + transferEncoding: headers[.transferEncoding] + ) return HTTPResponse( version: version, @@ -127,11 +135,46 @@ struct HTTPDecoder { .reduce(into: [HTTPHeader: String]()) { $0[$1.header] = $1.value } } - func readBody(from bytes: some AsyncBufferedSequence, length: String?) async throws -> HTTPBodySequence { - let length = length.flatMap(Int.init) ?? 0 + func readBody( + from bytes: some AsyncBufferedSequence, + contentLength: String?, + transferEncoding: String? + ) async throws -> HTTPBodySequence { guard sharedRequestBufferSize > 0 else { throw SocketError.disconnected } + + // RFC 9112 §6.1 — reject simultaneous Content-Length and Transfer-Encoding. + if transferEncoding != nil && contentLength != nil { + throw Error("Content-Length and Transfer-Encoding cannot both be present") + } + + // RFC 9112 §6.3 #3 — Transfer-Encoding takes precedence. §6.1 requires + // `chunked` to be the final coding when present; only `chunked` is supported here. + if let transferEncoding { + let tokens = transferEncoding + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + guard tokens.last == "chunked" else { + throw Error("Unsupported Transfer-Encoding: \(transferEncoding)") + } + return HTTPBodySequence( + chunked: bytes, + suggestedBufferSize: sharedRequestBufferSize + ) + } + + // RFC 9112 §6.3 #5 — invalid Content-Length is an unrecoverable framing error. + let length: Int + if let contentLength { + guard let parsed = Int(contentLength), parsed >= 0 else { + throw Error("Invalid Content-Length: \(contentLength)") + } + length = parsed + } else { + length = 0 + } + if length <= sharedRequestBufferSize { return try await HTTPBodySequence(data: readData(from: bytes, length: length), suggestedBufferSize: length) } else if length <= sharedRequestReplaySize { diff --git a/FlyingFox/Sources/HTTPRequest.swift b/FlyingFox/Sources/HTTPRequest.swift index 14a823b..db9aca8 100644 --- a/FlyingFox/Sources/HTTPRequest.swift +++ b/FlyingFox/Sources/HTTPRequest.swift @@ -129,7 +129,15 @@ public extension HTTPRequest { } extension HTTPRequest { + // RFC 9112 §9.3 — HTTP/1.1 connections persist unless `Connection: close`; + // HTTP/1.0 connections close unless `Connection: keep-alive` is present. var shouldKeepAlive: Bool { - headers[.connection]?.caseInsensitiveCompare("keep-alive") == .orderedSame + let tokens = (headers[.connection] ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + if version == .http11 { + return !tokens.contains("close") + } + return tokens.contains("keep-alive") } } diff --git a/FlyingFox/Sources/HTTPServer.swift b/FlyingFox/Sources/HTTPServer.swift index 72b3cef..957adf9 100644 --- a/FlyingFox/Sources/HTTPServer.swift +++ b/FlyingFox/Sources/HTTPServer.swift @@ -234,6 +234,9 @@ public final actor HTTPServer { try await connection.sendResponse(response) } } catch { + // TODO: send `400 Bad Request` on `HTTPDecoder.Error` before closing. + // Closing without a response is RFC 9112 §6.3 #5-compliant for unrecoverable + // framing errors, but a 400 response would be more informative. logger.logError(error, on: connection) } connections.remove(connection) @@ -243,7 +246,9 @@ public final actor HTTPServer { func handleRequest(_ request: HTTPRequest) async -> HTTPResponse { var response = await handleRequest(request, timeout: config.timeout) - if request.shouldKeepAlive { + // Echo the request's Connection header on keep-alive responses, but only + // if the handler did not set its own (e.g. WebSocket upgrade → "upgrade"). + if request.shouldKeepAlive, response.headers[.connection] == nil { response.headers[.connection] = request.headers[.connection] } return response diff --git a/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift index fd7c079..737a7d9 100644 --- a/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift +++ b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift @@ -55,39 +55,41 @@ public struct DirectoryHTTPHandler: HTTPHandler { } public func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse { - guard - let filePath = makeFileURL(for: request.path), - let data = try? Data(contentsOf: filePath) else { + guard let filePath = makeFileURL(for: request.path) else { return HTTPResponse(statusCode: .notFound) } - var headers: HTTPHeaders = [ - .contentType: FileHTTPHandler.makeContentType(for: filePath.absoluteString), - .cacheControl: cacheControl.getSerializedValue(), - .date: HTTPCacheControl.getDateHeaderValue() - ] + do { + var headers: HTTPHeaders = [ + .contentType: FileHTTPHandler.makeContentType(for: filePath.absoluteString), + .cacheControl: cacheControl.getSerializedValue(), + .date: HTTPDate.string(from: Date()) + ] - if let expiresValue = HTTPCacheControl.getExpiresValue(for: filePath) { - headers[.lastModified] = expiresValue - if let ifModifiedSince = request.headers[.ifModifiedSince], expiresValue == ifModifiedSince { - return HTTPResponse(statusCode: .notModified, - headers: headers) + if let expiresValue = HTTPCacheControl.getExpiresValue(for: filePath) { + headers[.lastModified] = expiresValue + if let ifModifiedSince = request.headers[.ifModifiedSince], expiresValue == ifModifiedSince { + return HTTPResponse(statusCode: .notModified, + headers: headers) + } } - } - if let eTagValue = HTTPCacheControl.getETagValue(for: data) { - headers[.eTag] = eTagValue - if let ifNoneMatch = request.headers[.ifNoneMatch], eTagValue == ifNoneMatch { - return HTTPResponse(statusCode: .notModified, - headers: headers) + if let eTagValue = HTTPCacheControl.getETagValue(for: filePath) { + headers[.eTag] = eTagValue + if let ifNoneMatch = request.headers[.ifNoneMatch], eTagValue == ifNoneMatch { + return HTTPResponse(statusCode: .notModified, + headers: headers) + } } - } - return HTTPResponse( - statusCode: .ok, - headers: headers, - body: data - ) + return try HTTPResponse( + statusCode: .ok, + headers: headers, + body: HTTPBodySequence(file: filePath) + ) + } catch { + return HTTPResponse(statusCode: .notFound) + } } func makeFileURL(for requestPath: String) -> URL? { diff --git a/FlyingFox/Sources/Handlers/FileHTTPHandler.swift b/FlyingFox/Sources/Handlers/FileHTTPHandler.swift index d87f503..256e80d 100644 --- a/FlyingFox/Sources/Handlers/FileHTTPHandler.swift +++ b/FlyingFox/Sources/Handlers/FileHTTPHandler.swift @@ -131,7 +131,7 @@ public struct FileHTTPHandler: HTTPHandler { .contentType: contentType, .acceptRanges: "bytes", .cacheControl: cacheControl.getSerializedValue(), - .date: HTTPCacheControl.getDateHeaderValue() + .date: HTTPDate.string(from: Date()) ] if let expiresValue = HTTPCacheControl.getExpiresValue(for: path) { @@ -142,8 +142,7 @@ public struct FileHTTPHandler: HTTPHandler { } } - if let data = try? Data(contentsOf: path), - let eTagValue = HTTPCacheControl.getETagValue(for: data) { + if let eTagValue = HTTPCacheControl.getETagValue(for: path) { headers[.eTag] = eTagValue if let ifNoneMatch = request.headers[.ifNoneMatch], eTagValue == ifNoneMatch { return HTTPResponse(statusCode: .notModified, diff --git a/FlyingFox/Sources/WebSocket/WSFrameEncoder.swift b/FlyingFox/Sources/WebSocket/WSFrameEncoder.swift index c1b1a1d..07c574b 100644 --- a/FlyingFox/Sources/WebSocket/WSFrameEncoder.swift +++ b/FlyingFox/Sources/WebSocket/WSFrameEncoder.swift @@ -142,14 +142,14 @@ struct WSFrameEncoder { UInt16(bytes.take()) return try await (Int(length), hasMask ? decodeMask(from: bytes) : nil) default: - var length = try await UInt64(bytes.take()) - length |= try await UInt64(bytes.take()) << 8 - length |= try await UInt64(bytes.take()) << 16 - length |= try await UInt64(bytes.take()) << 24 - length |= try await UInt64(bytes.take()) << 32 - length |= try await UInt64(bytes.take()) << 40 + var length = try await UInt64(bytes.take()) << 56 length |= try await UInt64(bytes.take()) << 48 - length |= try await UInt64(bytes.take()) << 56 + length |= try await UInt64(bytes.take()) << 40 + length |= try await UInt64(bytes.take()) << 32 + length |= try await UInt64(bytes.take()) << 24 + length |= try await UInt64(bytes.take()) << 16 + length |= try await UInt64(bytes.take()) << 8 + length |= try await UInt64(bytes.take()) guard length <= Int.max else { throw Error("Length is greater than Int.max") diff --git a/FlyingFox/Sources/WebSocket/WSFrameValidator.swift b/FlyingFox/Sources/WebSocket/WSFrameValidator.swift index 0437351..9df7b19 100644 --- a/FlyingFox/Sources/WebSocket/WSFrameValidator.swift +++ b/FlyingFox/Sources/WebSocket/WSFrameValidator.swift @@ -45,6 +45,16 @@ struct WSFrameValidator: Sendable { @Sendable func validateFrame(_ frame: WSFrame) throws -> WSFrame? { + switch frame.opcode { + case .ping, .pong, .close: + // RFC 6455 §5.5: control frame payload MUST be ≤ 125 bytes. + guard frame.payload.count <= 125 else { + throw Error("Control frame payload exceeds 125 bytes") + } + default: + break + } + if frame.opcode == .continuation { try appendContinuation(frame) guard let last = last, frame.fin else { diff --git a/FlyingFox/Sources/WebSocket/WSHandler.swift b/FlyingFox/Sources/WebSocket/WSHandler.swift index 29e3928..f2f3660 100644 --- a/FlyingFox/Sources/WebSocket/WSHandler.swift +++ b/FlyingFox/Sources/WebSocket/WSHandler.swift @@ -97,6 +97,10 @@ public struct MessageFrameWSHandler: WSHandler { messagesIn: AsyncStream.Continuation, messagesOut: AsyncStream ) async where S.Element == WSFrame { + defer { + messagesIn.finish() + framesOut.finish() + } await withTaskGroup(of: Void.self) { group in group.addTask { do { @@ -110,30 +114,24 @@ public struct MessageFrameWSHandler: WSHandler { throw FrameError.closed(frame) } } - framesOut.finish(throwing: nil) } catch FrameError.closed(let frame) { framesOut.yield(frame) - framesOut.finish(throwing: nil) + } catch is CancellationError { } catch { framesOut.finish(throwing: error) } } group.addTask { - do { - for await message in messagesOut { - for frame in makeFrames(for: message) { - framesOut.yield(frame) - if frame.opcode == .close { - throw FrameError.closed(frame) - } + for await message in messagesOut { + for frame in makeFrames(for: message) { + framesOut.yield(frame) + if frame.opcode == .close { + return } } - framesOut.finish(throwing: nil) - } catch { - framesOut.finish(throwing: nil) } } - await group.next()! + await group.next() group.cancelAll() } } diff --git a/FlyingFox/Tests/HTTPCacheControlTests.swift b/FlyingFox/Tests/HTTPCacheControlTests.swift new file mode 100644 index 0000000..512517d --- /dev/null +++ b/FlyingFox/Tests/HTTPCacheControlTests.swift @@ -0,0 +1,117 @@ +// +// HTTPCacheControlTests.swift +// FlyingFox +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// + +@testable import FlyingFox +import Foundation +import Testing + +struct HTTPCacheControlTests { + + @Test + func getETagValue_returnsNil_whenFileIsMissing() { + let missing = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("flyingfox-etag-missing-\(UUID().uuidString)") + #expect(HTTPCacheControl.getETagValue(for: missing) == nil) + } + + @Test + func getETagValue_isStrong_quoted_andHasNoWeakPrefix() throws { + let url = try Self.makeTempFile(contents: Data("hello".utf8)) + defer { try? FileManager.default.removeItem(at: url) } + + let etag = try #require(HTTPCacheControl.getETagValue(for: url)) + + // Strong validator: starts/ends with a literal double quote, no W/ prefix. + // Matches nginx "-" inside double quotes. + #expect(etag.hasPrefix("\"")) + #expect(etag.hasSuffix("\"")) + #expect(!etag.hasPrefix("W/")) + + let inner = etag.dropFirst().dropLast() + let parts = inner.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + #expect(parts.count == 2) + #expect(parts.allSatisfy { Self.isLowerHex($0) }) + } + + @Test + func getETagValue_isStable_forSameFile() throws { + let url = try Self.makeTempFile(contents: Data("stable".utf8)) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(HTTPCacheControl.getETagValue(for: url) == HTTPCacheControl.getETagValue(for: url)) + } + + @Test + func getETagValue_differs_whenSizeDiffers() throws { + let mtime = Date(timeIntervalSince1970: 1_700_000_000) + let small = try Self.makeTempFile(contents: Data("a".utf8), modificationDate: mtime) + defer { try? FileManager.default.removeItem(at: small) } + let bigger = try Self.makeTempFile(contents: Data("ab".utf8), modificationDate: mtime) + defer { try? FileManager.default.removeItem(at: bigger) } + + let etagSmall = try #require(HTTPCacheControl.getETagValue(for: small)) + let etagBigger = try #require(HTTPCacheControl.getETagValue(for: bigger)) + #expect(etagSmall != etagBigger) + } + + @Test + func getETagValue_differs_whenMtimeDiffers() throws { + let url = try Self.makeTempFile( + contents: Data("same-bytes".utf8), + modificationDate: Date(timeIntervalSince1970: 1_700_000_000) + ) + defer { try? FileManager.default.removeItem(at: url) } + let earlier = try #require(HTTPCacheControl.getETagValue(for: url)) + + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1_800_000_000)], + ofItemAtPath: url.path + ) + let later = try #require(HTTPCacheControl.getETagValue(for: url)) + + #expect(earlier != later) + } + + // The defining behavior of a metadata ETag: equal mtime + equal size → equal ETag, + // even if the file contents differ. This is exactly the property a SHA-256-of-contents + // ETag does NOT have. + @Test + func getETagValue_collides_whenMtimeAndSizeMatchButContentsDiffer() throws { + let mtime = Date(timeIntervalSince1970: 1_700_000_000) + let a = try Self.makeTempFile(contents: Data("AAAAA".utf8), modificationDate: mtime) + defer { try? FileManager.default.removeItem(at: a) } + let b = try Self.makeTempFile(contents: Data("BBBBB".utf8), modificationDate: mtime) + defer { try? FileManager.default.removeItem(at: b) } + + let etagA = try #require(HTTPCacheControl.getETagValue(for: a)) + let etagB = try #require(HTTPCacheControl.getETagValue(for: b)) + #expect(etagA == etagB) + } + + private static func makeTempFile( + contents: Data, + modificationDate: Date? = nil + ) throws -> URL { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("flyingfox-etag-\(UUID().uuidString)") + try contents.write(to: url) + if let modificationDate { + try FileManager.default.setAttributes( + [.modificationDate: modificationDate], + ofItemAtPath: url.path + ) + } + return url + } + + private static func isLowerHex(_ s: Substring) -> Bool { + !s.isEmpty && s.allSatisfy { $0.isHexDigit && (!$0.isLetter || $0.isLowercase) } + } +} diff --git a/FlyingFox/Tests/HTTPChunkedDecodedSequenceTests.swift b/FlyingFox/Tests/HTTPChunkedDecodedSequenceTests.swift new file mode 100644 index 0000000..86b0e21 --- /dev/null +++ b/FlyingFox/Tests/HTTPChunkedDecodedSequenceTests.swift @@ -0,0 +1,112 @@ +// +// HTTPChunkedDecodedSequenceTests.swift +// FlyingFox +// +// Created by Ian Gordon on 27/04/2026. +// Copyright © 2026 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +@testable import FlyingFox +import FlyingSocks +import Foundation +import Testing + +struct HTTPChunkedDecodedSequenceTests { + + // RFC 9112 §7.1 — once the trailer's terminating CRLF is consumed, the + // decoder must stop. Over-consuming would eat the next pipelined request + // on a keep-alive connection. + @Test + func decoder_DoesNotConsumeBeyondTerminator() async throws { + let wire: [UInt8] = Array("5\r\nHello\r\n0\r\n\r\nNEXT".utf8) + let source = ConsumingAsyncSequence(bytes: wire) + + var decoded = [UInt8]() + var iterator = HTTPChunkedTransferDecoder(bytes: source).makeAsyncIterator() + while let buffer = try await iterator.nextBuffer(suggested: 1024) { + decoded.append(contentsOf: buffer) + } + #expect(decoded == Array("Hello".utf8)) + + var trailing = [UInt8]() + var sourceIterator = source.makeAsyncIterator() + while let buffer = try await sourceIterator.nextBuffer(suggested: 1024) { + trailing.append(contentsOf: buffer) + } + #expect(trailing == Array("NEXT".utf8)) + } + + @Test + func decoder_HonorsSuggestedBufferCount() async throws { + let payload = String(repeating: "x", count: 100) + let wire: [UInt8] = Array("64\r\n\(payload)\r\n0\r\n\r\n".utf8) + + var iterator = HTTPChunkedTransferDecoder( + bytes: ConsumingAsyncSequence(bytes: wire) + ).makeAsyncIterator() + var sizes = [Int]() + while let buffer = try await iterator.nextBuffer(suggested: 16) { + sizes.append(buffer.count) + } + + #expect(sizes.allSatisfy { $0 <= 16 }) + #expect(sizes.reduce(0, +) == 100) + } + + // RFC 9112 §7.1 — `chunk-size = 1*HEXDIG`. Per RFC 5234 §2.3, ABNF literal + // strings match case-insensitively, so lowercase `a-f` is also valid. + @Test + func decoder_AcceptsUppercaseHexChunkSize() async throws { + let payload = String(repeating: "x", count: 0xFF) + let wire: [UInt8] = Array("FF\r\n\(payload)\r\n0\r\n\r\n".utf8) + + var iterator = HTTPChunkedTransferDecoder( + bytes: ConsumingAsyncSequence(bytes: wire) + ).makeAsyncIterator() + var decoded = [UInt8]() + while let buffer = try await iterator.nextBuffer(suggested: 1024) { + decoded.append(contentsOf: buffer) + } + + #expect(decoded.count == 0xFF) + } + + // A chunk-size larger than `Int.max` cannot be represented; `Int(_, radix:)` + // returns nil and the decoder must reject it as a framing error rather than + // silently truncating or trapping. + @Test + func decoder_RejectsChunkSizeExceedingIntMax() async throws { + let wire: [UInt8] = Array("FFFFFFFFFFFFFFFFFFFF\r\n".utf8) + + var iterator = HTTPChunkedTransferDecoder( + bytes: ConsumingAsyncSequence(bytes: wire) + ).makeAsyncIterator() + + await #expect(throws: HTTPDecoder.Error.self) { + _ = try await iterator.nextBuffer(suggested: 1024) + } + } +} diff --git a/FlyingFox/Tests/HTTPClientTests.swift b/FlyingFox/Tests/HTTPClientTests.swift index 1df9252..8887bb7 100644 --- a/FlyingFox/Tests/HTTPClientTests.swift +++ b/FlyingFox/Tests/HTTPClientTests.swift @@ -30,7 +30,6 @@ // #if canImport(Darwin) -@_spi(Private) import struct FlyingFox._HTTPClient @testable import FlyingFox @testable import FlyingSocks import Foundation @@ -44,7 +43,7 @@ struct HTTPClientTests { let server = HTTPServer(address: .loopback(port: 0)) let task = Task { try await server.run() } defer { task.cancel() } - let client = _HTTPClient() + var client = HTTPClient() // when let port = try await server.waitForListeningPort() diff --git a/FlyingFox/Tests/HTTPConnectionTests.swift b/FlyingFox/Tests/HTTPConnectionTests.swift index 59dced0..c41494e 100644 --- a/FlyingFox/Tests/HTTPConnectionTests.swift +++ b/FlyingFox/Tests/HTTPConnectionTests.swift @@ -83,6 +83,7 @@ struct HTTPConnectionTests { Connection: Keep-Alive\r \r GET /hello HTTP/1.1\r + Connection: close\r \r """ @@ -104,20 +105,47 @@ struct HTTPConnectionTests { try await connection.sendResponse( .make(version: .http11, statusCode: .gone, + headers: [.date: "Sun, 06 Nov 1994 08:49:37 GMT"], body: "Hello World!".data(using: .utf8)!) ) - let response = try await s2.readString(length: 53) + let response = try await s2.readString(length: 90) #expect( response == """ HTTP/1.1 410 Gone\r Content-Length: 12\r + Date: Sun, 06 Nov 1994 08:49:37 GMT\r \r Hello World! """ ) } + @Test + func connectionResponse_IncludesGeneratedDateHeader() async throws { + let (s1, s2) = try await AsyncSocket.makePair() + + let connection = HTTPConnection(socket: s1) + + try await connection.sendResponse( + .make(version: .http11, + statusCode: .ok, + body: "Hello World!".data(using: .utf8)!) + ) + + // IMF-fixdate is fixed width so the response length is deterministic. + let response = try await s2.readString(length: 88) + let expected = #""" + ^HTTP/1\.1 200 OK\r\nContent-Length: 12\r\nDate: (Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT\r\n\r\nHello World!$ + """# + #expect( + response.range(of: expected, options: .regularExpression) != nil + ) + + try s1.close() + try s2.close() + } + @Test func connectionDisconnects_WhenErrorIsReceived() async throws { let (s1, s2) = try await AsyncSocket.makePair() diff --git a/FlyingFox/Tests/HTTPDateTests.swift b/FlyingFox/Tests/HTTPDateTests.swift new file mode 100644 index 0000000..5ebc1e1 --- /dev/null +++ b/FlyingFox/Tests/HTTPDateTests.swift @@ -0,0 +1,46 @@ +// +// HTTPDateTests.swift +// FlyingFox +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// + +@testable import FlyingFox +import Foundation +import Testing + +struct HTTPDateTests { + + @Test + func stringFromDate_isIMFFixdate() { + // Example dates from RFC 9110 §5.6.7 and §6.6.1 + #expect( + HTTPDate.string(from: Date(timeIntervalSince1970: 784111777)) == "Sun, 06 Nov 1994 08:49:37 GMT" + ) + #expect( + HTTPDate.string(from: Date(timeIntervalSince1970: 784887151)) == "Tue, 15 Nov 1994 08:12:31 GMT" + ) + } + + @Test + func stringFromDate_zeroPadsDayOfMonth() { + #expect( + HTTPDate.string(from: Date(timeIntervalSince1970: 1767323045)) == "Fri, 02 Jan 2026 03:04:05 GMT" + ) + } + + @Test + func stringFromDate_roundTripsThroughIMFFixdateParser() { + let date = Date(timeIntervalSince1970: 784111777) + let parser = DateFormatter() + parser.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + parser.timeZone = TimeZone(secondsFromGMT: 0) + parser.locale = Locale(identifier: "en_US_POSIX") + #expect( + parser.date(from: HTTPDate.string(from: date)) == date + ) + } +} diff --git a/FlyingFox/Tests/HTTPDecoderTests.swift b/FlyingFox/Tests/HTTPDecoderTests.swift index dee1396..6d5ba8c 100644 --- a/FlyingFox/Tests/HTTPDecoderTests.swift +++ b/FlyingFox/Tests/HTTPDecoderTests.swift @@ -187,10 +187,10 @@ struct HTTPDecoderTests { @Test func body_ThrowsError_WhenSequenceEnds() async throws { await #expect(throws: SocketError.self) { - try await HTTPDecoder.make(sharedRequestReplaySize: 1024).readBody(from: AsyncBufferedEmptySequence(completeImmediately: true), length: "100").get() + try await HTTPDecoder.make(sharedRequestReplaySize: 1024).readBody(from: AsyncBufferedEmptySequence(completeImmediately: true), contentLength: "100", transferEncoding: nil).get() } await #expect(throws: SocketError.self) { - try await HTTPDecoder.make(sharedRequestBufferSize: 1024).readBody(from: AsyncBufferedEmptySequence(completeImmediately: true), length: "100").get() + try await HTTPDecoder.make(sharedRequestBufferSize: 1024).readBody(from: AsyncBufferedEmptySequence(completeImmediately: true), contentLength: "100", transferEncoding: nil).get() } } @@ -302,6 +302,209 @@ struct HTTPDecoderTests { HTTPDecoder.standardizePath("/../a/b/../c/./d.html", fallback: true) == "/a/c/d.html" ) } + + // RFC 9112 §7.1 — chunked-coded request bodies are decoded into the body data. + @Test + func body_IsParsed_WhenTransferEncoding_IsChunked() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5\r + Hello\r + 7\r + World!\r + 0\r + \r + + """ + ) + + #expect(try await request.bodyString == "Hello World!") + } + + @Test + func body_IsEmpty_WhenChunkedTerminatorOnly() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 0\r + \r + + """ + ) + + #expect(try await request.bodyData == Data()) + } + + // RFC 9112 §7.1.2 — trailer fields are consumed but not part of the body. + @Test + func chunkedBody_IgnoresTrailerHeaders() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5\r + Hello\r + 0\r + X-Trailer: ignored\r + \r + + """ + ) + + #expect(try await request.bodyString == "Hello") + } + + // RFC 9112 §6.1 — reject simultaneous Content-Length and Transfer-Encoding (smuggling prevention). + @Test + func contentLengthAndTransferEncoding_ThrowsError() async throws { + await #expect(throws: HTTPDecoder.Error.self) { + try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Content-Length: 5\r + Transfer-Encoding: chunked\r + \r + Hello + """ + ) + } + } + + // RFC 9112 §6.3 #5 — invalid Content-Length is an unrecoverable framing error. + @Test + func nonNumericContentLength_ThrowsError() async throws { + await #expect(throws: HTTPDecoder.Error.self) { + try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Content-Length: abc\r + \r + """ + ) + } + } + + @Test + func negativeContentLength_ThrowsError() async throws { + await #expect(throws: HTTPDecoder.Error.self) { + try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Content-Length: -5\r + \r + """ + ) + } + } + + // RFC 9112 §7.1 — `chunk-ext` (preceded by `;`) is permitted on the chunk-size line. + @Test + func chunkExt_IsIgnored() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5;name=value\r + Hello\r + 0\r + \r + + """ + ) + + #expect(try await request.bodyString == "Hello") + } + + @Test + func invalidChunkSize_ThrowsError() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + XX\r + \r + + """ + ) + + await #expect(throws: HTTPDecoder.Error.self) { + _ = try await request.bodyData + } + } + + @Test + func missingCRLFAfterChunkData_ThrowsError() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5\r + HelloAB + """ + ) + + await #expect(throws: HTTPDecoder.Error.self) { + _ = try await request.bodyData + } + } + + // Truncated chunked-body: chunk-size line never terminates → SocketError.disconnected. + @Test + func truncatedChunkSize_ThrowsError() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5 + """ + ) + + await #expect(throws: SocketError.self) { + _ = try await request.bodyData + } + } + + // Truncated chunked-body: stream ends inside chunk-data → SocketError.disconnected. + @Test + func truncatedChunkData_ThrowsError() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5\r + Hi + """ + ) + + await #expect(throws: SocketError.self) { + _ = try await request.bodyData + } + } + + // RFC 9112 §6.1 — only `chunked` transfer coding is supported here. + @Test + func unsupportedTransferEncoding_ThrowsError() async throws { + await #expect(throws: HTTPDecoder.Error.self) { + try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: gzip\r + \r + """ + ) + } + } } private extension HTTPDecoder { @@ -318,7 +521,8 @@ private extension HTTPDecoder { let data = string.data(using: .utf8)! return try await readBody( from: ConsumingAsyncSequence(data), - length: "\(data.count)" + contentLength: "\(data.count)", + transferEncoding: nil ) } } diff --git a/FlyingFox/Tests/HTTPRequestTests.swift b/FlyingFox/Tests/HTTPRequestTests.swift index cfb1e44..33ee520 100644 --- a/FlyingFox/Tests/HTTPRequestTests.swift +++ b/FlyingFox/Tests/HTTPRequestTests.swift @@ -88,4 +88,61 @@ struct HTTPRequestTests { #expect(request.target.query() == "food=fish%20%26%20chips&qty=15") #expect(request.target.query(percentEncoded: false) == "food=fish & chips&qty=15") } + + // RFC 9112 §9.3 — HTTP/1.1 connections persist by default; only `Connection: close` opts out. + @Test + func http11_keepsAliveByDefault_whenConnectionHeaderAbsent() { + let request = HTTPRequest.make(version: .http11, headers: [:]) + #expect(request.shouldKeepAlive) + } + + @Test + func http11_closes_whenConnectionHeaderIsClose() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "close"]) + #expect(!request.shouldKeepAlive) + } + + @Test + func http11_closes_whenConnectionHeaderIsCloseMixedCase() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "Close"]) + #expect(!request.shouldKeepAlive) + } + + @Test + func http11_keepsAlive_whenConnectionHeaderIsKeepAlive() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "keep-alive"]) + #expect(request.shouldKeepAlive) + } + + // RFC 9110 §7.6.1 — Connection is a comma-separated list of options. + @Test + func http11_keepsAlive_withMultiTokenConnectionHeader() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "keep-alive, Upgrade"]) + #expect(request.shouldKeepAlive) + } + + @Test + func http11_closes_whenCloseTokenAppearsAmongOthers() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "Upgrade, close"]) + #expect(!request.shouldKeepAlive) + } + + // RFC 9112 §9.3 — HTTP/1.0 closes by default; only `Connection: keep-alive` opts in. + @Test + func http10_closesByDefault_whenConnectionHeaderAbsent() { + let request = HTTPRequest.make(version: HTTPVersion("HTTP/1.0"), headers: [:]) + #expect(!request.shouldKeepAlive) + } + + @Test + func http10_keepsAlive_whenConnectionHeaderIsKeepAlive() { + let request = HTTPRequest.make(version: HTTPVersion("HTTP/1.0"), headers: [.connection: "keep-alive"]) + #expect(request.shouldKeepAlive) + } + + @Test + func http10_closes_whenConnectionHeaderIsClose() { + let request = HTTPRequest.make(version: HTTPVersion("HTTP/1.0"), headers: [.connection: "close"]) + #expect(!request.shouldKeepAlive) + } } diff --git a/FlyingFox/Tests/HTTPServerTests.swift b/FlyingFox/Tests/HTTPServerTests.swift index 995d032..06dc681 100644 --- a/FlyingFox/Tests/HTTPServerTests.swift +++ b/FlyingFox/Tests/HTTPServerTests.swift @@ -245,6 +245,59 @@ actor HTTPServerTests { ) } + @Test + func unhandledRequest_ResponseIncludesDateHeader() async throws { + let server = HTTPServer.make() + let port = try await startServerWithPort(server) + + let socket = try await AsyncSocket.connected(to: .inet(ip4: "127.0.0.1", port: port)) + defer { try? socket.close() } + + try await socket.writeRequest(.make("/missing")) + let response = try await socket.readResponse() + #expect(response.statusCode == .notFound) + #expect(Self.isIMFFixdate(response.headers[.date])) + } + + @Test + func handlerError_ResponseIncludesDateHeader() async throws { + let server = HTTPServer.make() { _ in + throw SocketError.disconnected + } + let port = try await startServerWithPort(server) + + let socket = try await AsyncSocket.connected(to: .inet(ip4: "127.0.0.1", port: port)) + defer { try? socket.close() } + + try await socket.writeRequest(.make("/error")) + let response = try await socket.readResponse() + #expect(response.statusCode == .internalServerError) + #expect(Self.isIMFFixdate(response.headers[.date])) + } + + @Test + func handlerTimeout_ResponseIncludesDateHeader() async throws { + let server = HTTPServer.make(timeout: 0.1) { _ in + try await Task.sleep(seconds: 1) + return HTTPResponse.make(statusCode: .accepted) + } + let port = try await startServerWithPort(server) + + let socket = try await AsyncSocket.connected(to: .inet(ip4: "127.0.0.1", port: port)) + defer { try? socket.close() } + + try await socket.writeRequest(.make("/slow")) + let response = try await socket.readResponse() + #expect(response.statusCode == .internalServerError) + #expect(Self.isIMFFixdate(response.headers[.date])) + } + + static func isIMFFixdate(_ value: String?) -> Bool { + guard let value else { return false } + let pattern = #"^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$"# + return value.range(of: pattern, options: .regularExpression) != nil + } + @Test func keepAlive_IsAddedToResponses() async throws { let server = HTTPServer.make() diff --git a/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift b/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift index c81fee1..17d0858 100644 --- a/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift +++ b/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift @@ -72,6 +72,82 @@ struct DirectoryHTTPHandlerTests { ) } + @Test + func directoryHandler_streamsBody_fromFile() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let response = try await handler.handleRequest(.make(path: "server/path/fish.json")) + guard case .httpBody(let body) = response.payload else { + Issue.record("expected .httpBody payload") + return + } + #expect(body.storage.sequence is AsyncBufferedFileSequence) + } + + @Test + func directoryHandler_setsCacheHeaders_on200() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let response = try await handler.handleRequest(.make(path: "server/path/fish.json")) + #expect(response.statusCode == .ok) + #expect(response.headers[.cacheControl]?.isEmpty == false) + #expect(response.headers[.date]?.isEmpty == false) + #expect(response.headers[.lastModified]?.isEmpty == false) + #expect(response.headers[.eTag]?.isEmpty == false) + } + + @Test + func directoryHandler_returns304_whenIfModifiedSinceMatches() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let initial = try await handler.handleRequest(.make(path: "server/path/fish.json")) + let lastModified = try #require(initial.headers[.lastModified]) + + let response = try await handler.handleRequest(.make( + path: "server/path/fish.json", + headers: [.ifModifiedSince: lastModified] + )) + #expect(response.statusCode == .notModified) + #expect(response.headers[.lastModified] == lastModified) + } + + @Test + func directoryHandler_returns200_whenIfModifiedSinceDoesNotMatch() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let response = try await handler.handleRequest(.make( + path: "server/path/fish.json", + headers: [.ifModifiedSince: "Mon, 01 Jan 1990 00:00:00 GMT"] + )) + #expect(response.statusCode == .ok) + } + + @Test + func directoryHandler_returns304_whenIfNoneMatchMatches() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let initial = try await handler.handleRequest(.make(path: "server/path/fish.json")) + let etag = try #require(initial.headers[.eTag]) + + let response = try await handler.handleRequest(.make( + path: "server/path/fish.json", + headers: [.ifNoneMatch: etag] + )) + #expect(response.statusCode == .notModified) + #expect(response.headers[.eTag] == etag) + } + + @Test + func directoryHandler_returns200_whenIfNoneMatchDoesNotMatch() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let response = try await handler.handleRequest(.make( + path: "server/path/fish.json", + headers: [.ifNoneMatch: "\"deadbeef-0\""] + )) + #expect(response.statusCode == .ok) + } + @Test func directoryHandler_Returns404WhenFileDoesNotExist() async throws { let handler = DirectoryHTTPHandler.directory(for: .module, subPath: "Stubs", serverPath: "server/path") diff --git a/FlyingFox/Tests/WebSocket/WSFrameEncoderTests.swift b/FlyingFox/Tests/WebSocket/WSFrameEncoderTests.swift index 592a62b..d7865aa 100644 --- a/FlyingFox/Tests/WebSocket/WSFrameEncoderTests.swift +++ b/FlyingFox/Tests/WebSocket/WSFrameEncoderTests.swift @@ -231,12 +231,18 @@ struct WSFrameEncoderTests { @Test func decodeLength() async throws { + #expect( + try await WSFrameEncoder.decodeLength(0x00) == 0 + ) #expect( try await WSFrameEncoder.decodeLength(0x01) == 1 ) #expect( try await WSFrameEncoder.decodeLength(0x7D) == 125 ) + #expect( + try await WSFrameEncoder.decodeLength(0x7E, 0x00, 0x7E) == 126 + ) #expect( try await WSFrameEncoder.decodeLength(0x7E, 0x00, 0xFF) == 0x00FF ) @@ -247,7 +253,22 @@ struct WSFrameEncoderTests { try await WSFrameEncoder.decodeLength(0x7E, 0xFF, 0xFF) == 0xFFFF ) #expect( - try await WSFrameEncoder.decodeLength(0x7F, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x00) == 0x0099AABBCCDDEEFF + try await WSFrameEncoder.decodeLength(0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00) == 0x00010000 + ) + #expect( + try await WSFrameEncoder.decodeLength(0x7F, 0x00, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF) == 0x0099AABBCCDDEEFF + ) + #expect( + try await WSFrameEncoder.decodeLength(0x7F, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF) == Int.max + ) + #expect( + try await WSFrameEncoder.decodeLength(0x80, 0x00, 0x00, 0x00, 0x00) == 0x00 + ) + #expect( + try await WSFrameEncoder.decodeLength(0xFE, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x00) == 0x7E + ) + #expect( + try await WSFrameEncoder.decodeLength(0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x00, 0x00) == 0x7F ) } @@ -256,9 +277,18 @@ struct WSFrameEncoderTests { await #expect(throws: SocketError.disconnected) { try await WSFrameEncoder.decodeLength(0x7E) } + await #expect(throws: SocketError.disconnected) { + try await WSFrameEncoder.decodeLength(0x7E, 0x00) + } await #expect(throws: SocketError.disconnected) { try await WSFrameEncoder.decodeLength(0x7F, 0xFF, 0xFF, 0xFF) } + await #expect(throws: SocketError.disconnected) { + try await WSFrameEncoder.decodeLength(0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + } + await #expect(throws: WSFrameEncoder.Error.self) { + try await WSFrameEncoder.decodeLength(0x7F, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + } await #expect(throws: WSFrameEncoder.Error.self) { try await WSFrameEncoder.decodeLength(0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF) } diff --git a/FlyingFox/Tests/WebSocket/WSFrameValidatorTests.swift b/FlyingFox/Tests/WebSocket/WSFrameValidatorTests.swift index 21d5ba0..dc5dd9c 100644 --- a/FlyingFox/Tests/WebSocket/WSFrameValidatorTests.swift +++ b/FlyingFox/Tests/WebSocket/WSFrameValidatorTests.swift @@ -80,6 +80,31 @@ struct WSFrameValidatorTests { } } + @Test + func controlFrames_throwError_whenPayloadExceeds125Bytes() async { + // RFC 6455 §5.5: control frames MUST have payload length ≤ 125 bytes. + let oversized = Data(repeating: 0x41, count: 126) + await #expect(throws: WSFrameValidator.Error.self) { + try await WSFrameValidator.validate([.make(opcode: .ping, payload: oversized)]).collectAll() + } + await #expect(throws: WSFrameValidator.Error.self) { + try await WSFrameValidator.validate([.make(opcode: .pong, payload: oversized)]).collectAll() + } + await #expect(throws: WSFrameValidator.Error.self) { + try await WSFrameValidator.validate([.make(opcode: .close, payload: oversized)]).collectAll() + } + } + + @Test + func controlFrames_areAccepted_whenPayloadIsAtMost125Bytes() async throws { + let maxPayload = Data(repeating: 0x41, count: 125) + let ping = WSFrame.make(opcode: .ping, payload: maxPayload) + let emptyPing = WSFrame.make(opcode: .ping) + #expect( + try await WSFrameValidator.validate([ping, emptyPing]).collectAll() == [ping, emptyPing] + ) + } + @Test func controlFrames_ThrowError_WhenNotFin() async { await #expect(throws: WSFrameValidator.Error.self) { diff --git a/FlyingFox/Tests/WebSocket/WSHandlerTests.swift b/FlyingFox/Tests/WebSocket/WSHandlerTests.swift index b40c737..0582210 100644 --- a/FlyingFox/Tests/WebSocket/WSHandlerTests.swift +++ b/FlyingFox/Tests/WebSocket/WSHandlerTests.swift @@ -137,6 +137,40 @@ struct WSHandlerTests { try await frames.collectAll() == [.pong] ) } + + @Test + func messagesOut_Ends_WhenCloseMessageIsSent() async throws { + let messages = Messages() + let handler = MessageFrameWSHandler.make(handler: messages) + let frames = try await handler.makeFrames(for: []) + + messages.output.yield(.close(.normalClosure)) + + #expect( + try await frames.collectAll() == [.close(code: .normalClosure)] + ) + } + + @Test + func messagesOut_YieldsFrames() async throws { + let messages = Messages() + let handler = MessageFrameWSHandler.make(handler: messages) + let (clientFrames, clientContinuation) = AsyncThrowingStream.makeStream() + + defer { + clientContinuation.finish() + messages.output.finish() + } + + let frames = try await handler.makeFrames(for: clientFrames) + + messages.output.yield(.text("Hello")) + + var iterator = frames.makeAsyncIterator() + #expect( + try await iterator.next() == .make(fin: true, opcode: .text, payload: "Hello".data(using: .utf8)!) + ) + } } extension MessageFrameWSHandler { diff --git a/FlyingSocks.podspec.json b/FlyingSocks.podspec.json index 52f460d..289cf63 100644 --- a/FlyingSocks.podspec.json +++ b/FlyingSocks.podspec.json @@ -1,6 +1,6 @@ { "name": "FlyingSocks", - "version": "0.26.2", + "version": "0.27.1", "summary": "Lightweight, async sockets written in Swift using async/await", "homepage": "https://github.com/swhitty/FlyingFox", "authors": "Simon Whitty", @@ -10,7 +10,7 @@ }, "source": { "git": "https://github.com/swhitty/FlyingFox.git", - "tag": "0.26.2" + "tag": "0.27.1" }, "platforms": { "ios": "13.0", diff --git a/FlyingSocks/Sources/AsyncBufferingSequence.swift b/FlyingSocks/Sources/AsyncBufferingSequence.swift new file mode 100644 index 0000000..734058b --- /dev/null +++ b/FlyingSocks/Sources/AsyncBufferingSequence.swift @@ -0,0 +1,131 @@ +// +// AsyncBufferingSequence.swift +// FlyingFox +// +// Wraps an AsyncBufferedSequence with a shared in-memory buffer so that +// multiple iterators created from the same wrapper consume from the same +// underlying stream without losing bytes pulled-but-not-yet-consumed when +// one iterator is dropped. +// +// Distributed under the permissive MIT license. +// + +private extension Transferring where Value: AsyncBufferedIteratorProtocol { + mutating func nextBuffer(suggested count: Int) async throws -> Transferring? { + guard let buffer = try await value.nextBuffer(suggested: count) else { return nil } + return Transferring(buffer) + } +} + +/// AsyncBufferedSequence that adds a shared in-memory buffer over a base +/// sequence. Bytes pulled from the base by one iterator remain available to +/// subsequent iterators on the same wrapper — required when a consumer +/// (e.g. the HTTP decoder) constructs multiple iterators against the same +/// stream and must not lose bytes between them. +/// +/// This is consuming, not replaying: each byte is returned to exactly one +/// `next()` / `nextBuffer(suggested:)` call across all iterators. +package struct AsyncBufferingSequence: AsyncBufferedSequence, Sendable +where Base: AsyncBufferedSequence, Base.Element: Sendable { + + package typealias Element = Base.Element + + private let storage: Storage + + package init(_ base: Base, suggestedBufferSize: Int = 4096) { + self.storage = Storage(base: base, suggestedBufferSize: suggestedBufferSize) + } + + package func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(storage: storage) + } + + package struct AsyncIterator: AsyncBufferedIteratorProtocol { + package typealias Buffer = ArraySlice + + private let storage: Storage + + init(storage: Storage) { + self.storage = storage + } + + package mutating func next() async throws -> Element? { + try await storage.popOne() + } + + package mutating func nextBuffer(suggested count: Int) async throws -> ArraySlice? { + try await storage.popBuffer(suggested: count) + } + } +} + +extension AsyncBufferingSequence { + + /// Storage actor backing one or more iterators against a base sequence. + /// + /// Designed for *serial* consumption from a single task graph (e.g. one + /// connection at a time): the actor's isolation guarantees a single in-flight + /// `refill` per wrapper, and the iterators created from `makeAsyncIterator()` + /// share the same backing buffer so bytes pulled from the base are never lost + /// between iterators. + final actor Storage { + + private var iterator: Base.AsyncIterator? // nil after EOF (or transiently during refill) + private var buffer: [Element] = [] + private var consumed: Int = 0 + private let suggestedBufferSize: Int + + init(base: Base, suggestedBufferSize: Int) { + self.iterator = base.makeAsyncIterator() + self.suggestedBufferSize = suggestedBufferSize + } + + private var available: Int { buffer.count - consumed } + + func popOne() async throws -> Element? { + if available == 0, try await refill(suggested: suggestedBufferSize) == false { + return nil + } + let element = buffer[consumed] + consumed += 1 + return element + } + + func popBuffer(suggested count: Int) async throws -> ArraySlice? { + guard count > 0 else { return [] } + if available == 0, + try await refill(suggested: Swift.max(count, suggestedBufferSize)) == false { + return nil + } + let take = Swift.min(count, available) + let slice = buffer[consumed..<(consumed + take)] + consumed += take + return slice + } + + // Returns true when bytes were pulled into the buffer, false at EOF. + // Wraps the iterator in `Transferring` to call a mutating async on a + // value-type iterator without tripping actor-isolation/sendability. + // Same idiom as AsyncSharedReplaySequence.requestNextChunk. + private func refill(suggested count: Int) async throws -> Bool { + guard let iter = iterator else { return false } + iterator = nil + var transferring = Transferring(iter) + let chunk: Base.AsyncIterator.Buffer? + do { + chunk = try await transferring.nextBuffer(suggested: count)?.value + } catch { + iterator = transferring.value + throw error + } + iterator = transferring.value + guard let chunk, !chunk.isEmpty else { + iterator = nil // EOF + return false + } + buffer = Array(chunk) + consumed = 0 + return true + } + } +} diff --git a/FlyingSocks/Sources/AsyncSocket.swift b/FlyingSocks/Sources/AsyncSocket.swift index 8d235e9..032096f 100644 --- a/FlyingSocks/Sources/AsyncSocket.swift +++ b/FlyingSocks/Sources/AsyncSocket.swift @@ -123,9 +123,14 @@ public struct AsyncSocket: Sendable { timeout: TimeInterval = 5) async throws -> Self { try await withThrowingTimeout(seconds: timeout) { let socket = try Socket(domain: Int32(type(of: address).family), type: .stream) - let asyncSocket = try AsyncSocket(socket: socket, pool: pool) - try await asyncSocket.connect(to: address) - return asyncSocket + do { + let asyncSocket = try AsyncSocket(socket: socket, pool: pool) + try await asyncSocket.connect(to: address) + return asyncSocket + } catch { + try? socket.close() + throw error + } } } @@ -134,7 +139,12 @@ public struct AsyncSocket: Sendable { try await pool.loopUntilReady(for: .connection, on: socket) { let file = try socket.accept().file let socket = Socket(file: file) - return try AsyncSocket(socket: socket, pool: pool) + do { + return try AsyncSocket(socket: socket, pool: pool) + } catch { + try? socket.close() + throw error + } } } diff --git a/FlyingSocks/Sources/Socket+Darwin.swift b/FlyingSocks/Sources/Socket+Darwin.swift index c36f674..2524411 100644 --- a/FlyingSocks/Sources/Socket+Darwin.swift +++ b/FlyingSocks/Sources/Socket+Darwin.swift @@ -89,12 +89,15 @@ extension Socket { static func makeAddressUnix(path: String) -> Darwin.sockaddr_un { var addr = Darwin.sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) - let pathCount = min(path.utf8.count, 104) + // Darwin declares `char sun_path[104]`; reserve the last byte + // for the NUL terminator that String(cString:) and unlink() read back. + let pathCount = min(path.utf8.count, 103) let len = UInt8(MemoryLayout.size + MemoryLayout.size + pathCount + 1) - _ = withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in + withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in path.withCString { - strncpy(ptr, $0, Int(len)) + _ = strncpy(ptr, $0, pathCount) } + ptr[pathCount] = 0 } addr.sun_len = len return addr diff --git a/FlyingSocks/Sources/Socket.swift b/FlyingSocks/Sources/Socket.swift index a72ef79..45dd43d 100644 --- a/FlyingSocks/Sources/Socket.swift +++ b/FlyingSocks/Sources/Socket.swift @@ -158,6 +158,7 @@ public struct Socket: Sendable, Hashable { public func getValue(for option: O) throws -> O.Value { let valuePtr = UnsafeMutablePointer.allocate(capacity: 1) + defer { valuePtr.deallocate() } var length = socklen_t(MemoryLayout.size) guard Socket.getsockopt(file.rawValue, option.level, option.name, valuePtr, &length) >= 0 else { throw SocketError.makeFailed("GetOption") @@ -257,9 +258,12 @@ public struct Socket: Sendable, Hashable { private func read(into buffer: UnsafeMutablePointer, length: Int) throws -> Int { let count = Socket.read(file.rawValue, buffer, length) guard count > 0 else { - if errno == EWOULDBLOCK { + // count == 0 is EOF; errno is only valid after a -1 return. + if count == 0 { + throw SocketError.disconnected + } else if errno == EWOULDBLOCK { throw SocketError.blocked - } else if errnoSignalsDisconnected() || count == 0 { + } else if errnoSignalsDisconnected() { throw SocketError.disconnected } else { throw SocketError.makeFailed("Read") @@ -292,9 +296,12 @@ public struct Socket: Sendable, Hashable { Socket.recvfrom(file.rawValue, buffer, length, 0, $0, &size) } guard count > 0 else { - if errno == EWOULDBLOCK { + // count == 0 is EOF; errno is only valid after a -1 return. + if count == 0 { + throw SocketError.disconnected + } else if errno == EWOULDBLOCK { throw SocketError.blocked - } else if errnoSignalsDisconnected() || count == 0 { + } else if errnoSignalsDisconnected() { throw SocketError.disconnected } else { throw SocketError.makeFailed("RecvFrom") @@ -357,9 +364,12 @@ public struct Socket: Sendable, Hashable { } guard count > 0 else { - if errno == EWOULDBLOCK || errno == EAGAIN { + // count == 0 is EOF; errno is only valid after a -1 return. + if count == 0 { + throw SocketError.disconnected + } else if errno == EWOULDBLOCK || errno == EAGAIN { throw SocketError.blocked - } else if errnoSignalsDisconnected() || count == 0 { + } else if errnoSignalsDisconnected() { throw SocketError.disconnected } else { throw SocketError.makeFailed("RecvMsg") diff --git a/FlyingSocks/Sources/SocketPool+ePoll.swift b/FlyingSocks/Sources/SocketPool+ePoll.swift index 7034e84..1a134ec 100644 --- a/FlyingSocks/Sources/SocketPool+ePoll.swift +++ b/FlyingSocks/Sources/SocketPool+ePoll.swift @@ -31,6 +31,13 @@ #if canImport(CSystemLinux) import CSystemLinux +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Android) +import Android +#endif public extension AsyncSocketPool where Self == SocketPool { static func ePoll(triggering: ePoll.TriggerMode = .edge, maxEvents limit: Int = 20, logger: some Logging = .disabled) -> SocketPool { @@ -147,6 +154,11 @@ public struct ePoll: EventQueue { var events = Array(repeating: epoll_event(), count: eventsLimit) let status = CSystemLinux.epoll_wait(file.rawValue, &events, Int32(eventsLimit), -1) guard status > 0 else { + // EINTR (signal) is not a failure: report no events so the caller + // polls again rather than tearing down the server. + if status == -1 && errno == EINTR { + return [] + } throw SocketError.makeFailed("epoll wait") } diff --git a/FlyingSocks/Sources/SocketPool+kQueue.swift b/FlyingSocks/Sources/SocketPool+kQueue.swift index d05dbe0..fff33f5 100644 --- a/FlyingSocks/Sources/SocketPool+kQueue.swift +++ b/FlyingSocks/Sources/SocketPool+kQueue.swift @@ -133,6 +133,11 @@ public struct kQueue: EventQueue { var events = Array(repeating: kevent(), count: eventsLimit) let status = kevent(file.rawValue, nil, 0, &events, Int32(eventsLimit), nil) guard status > 0 else { + // EINTR (signal) is not a failure: report no events so the caller + // polls again rather than tearing down the server. See kevent(2). + if status == -1 && errno == EINTR { + return [] + } throw SocketError.makeFailed("kqueue kevent") } diff --git a/FlyingSocks/Tests/AsyncSocketTests.swift b/FlyingSocks/Tests/AsyncSocketTests.swift index d8fe883..1237457 100644 --- a/FlyingSocks/Tests/AsyncSocketTests.swift +++ b/FlyingSocks/Tests/AsyncSocketTests.swift @@ -74,6 +74,16 @@ struct AsyncSocketTests { try await task.value } + @Test + func connected_ThrowsError_WhenConnectFails() async throws { + await #expect(throws: SocketError.self) { + _ = try await AsyncSocket.connected( + to: sockaddr_un.unix(path: "/nonexistent/\(UUID().uuidString)"), + pool: DisconnectedPool() + ) + } + } + @Test(.disabled("problematic test as file descriptor can be re-opened by another parallel test")) func socketReadByte_ThrowsDisconnected_WhenSocketIsClosed() async throws { let s1 = try await AsyncSocket.make() diff --git a/FlyingSocks/Tests/IdentifiableContinuationTests.swift b/FlyingSocks/Tests/IdentifiableContinuationTests.swift index 9e746e6..7d62e1d 100644 --- a/FlyingSocks/Tests/IdentifiableContinuationTests.swift +++ b/FlyingSocks/Tests/IdentifiableContinuationTests.swift @@ -140,7 +140,7 @@ struct IdentifiableContinuationAsyncTests { let waiter = Waiter() let task = await waiter.makeTask(onCancel: .failure(CancellationError())) - try? await Task.sleep(seconds: 0.1) + try? await Task.sleep(seconds: 0.5) var isEmpty = await waiter.isEmpty #expect(!isEmpty) task.cancel() @@ -159,7 +159,7 @@ struct IdentifiableContinuationAsyncTests { let waiter = Waiter() let task = await waiter.makeTask(delay: 1.0, onCancel: .failure(CancellationError())) - try? await Task.sleep(seconds: 0.1) + try? await Task.sleep(seconds: 0.5) let isEmpty = await waiter.isEmpty #expect(isEmpty) task.cancel() diff --git a/FlyingSocks/Tests/SocketAddressTests.swift b/FlyingSocks/Tests/SocketAddressTests.swift index 995ad74..9108199 100644 --- a/FlyingSocks/Tests/SocketAddressTests.swift +++ b/FlyingSocks/Tests/SocketAddressTests.swift @@ -148,6 +148,30 @@ struct SocketAddressTests { ) } + #if canImport(Darwin) + @Test + func unixMaxLengthPath_IsCorrectlyDecodedFromStorage() throws { + let path = "/tmp/" + String(repeating: "x", count: 98) + let addr = sockaddr_un.unix(path: path) + + #expect(Int(addr.sun_len) <= MemoryLayout.size) + #expect( + try Socket.makeAddress(from: addr.makeStorage()) == .unix(path) + ) + } + + @Test + func unixOverlongPath_TruncatesWithoutOverflow() throws { + let path = "/tmp/" + String(repeating: "x", count: 99) + let addr = sockaddr_un.unix(path: path) + + #expect(Int(addr.sun_len) <= MemoryLayout.size) + #expect( + try Socket.makeAddress(from: addr.makeStorage()) == .unix(String(path.prefix(103))) + ) + } + #endif + #if canImport(Glibc) || canImport(Musl) || canImport(Android) @Test func unixAbstractNamespace_IsCorrectlyDecodedFromStorage() throws { @@ -268,7 +292,7 @@ struct SocketAddressTests { func maximumPathLengthForUnixDomainSocket() { var addrUn = sockaddr_un() addrUn.sun_family = sa_family_t(AF_UNIX) - let maxPathLength = MemoryLayout.size - MemoryLayout.size - 1 + let maxPathLength = MemoryLayout.size(ofValue: addrUn.sun_path) - 1 let maxPath = String(repeating: "a", count: maxPathLength) _ = maxPath.withCString { pathPtr in memcpy(&addrUn.sun_path, pathPtr, maxPath.count + 1) diff --git a/README.md b/README.md index 2cc3cbd..abe6810 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ FlyingFox can be installed by using Swift Package Manager. To install using Swift Package Manager, add this to the `dependencies:` section in your Package.swift file: ```swift -.package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.26.0")) +.package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.27.1")) ``` # Usage