diff --git a/FlyingFox/Sources/HTTPCacheControl.swift b/FlyingFox/Sources/HTTPCacheControl.swift index 1d192f2..9b61d6e 100644 --- a/FlyingFox/Sources/HTTPCacheControl.swift +++ b/FlyingFox/Sources/HTTPCacheControl.swift @@ -55,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 = { @@ -78,7 +66,7 @@ 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 { } diff --git a/FlyingFox/Sources/HTTPConnection.swift b/FlyingFox/Sources/HTTPConnection.swift index 7f69d0e..59c93e3 100644 --- a/FlyingFox/Sources/HTTPConnection.swift +++ b/FlyingFox/Sources/HTTPConnection.swift @@ -63,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 { 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/Handlers/DirectoryHTTPHandler.swift b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift index fd35d5a..737a7d9 100644 --- a/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift +++ b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift @@ -63,7 +63,7 @@ public struct DirectoryHTTPHandler: HTTPHandler { var headers: HTTPHeaders = [ .contentType: FileHTTPHandler.makeContentType(for: filePath.absoluteString), .cacheControl: cacheControl.getSerializedValue(), - .date: HTTPCacheControl.getDateHeaderValue() + .date: HTTPDate.string(from: Date()) ] if let expiresValue = HTTPCacheControl.getExpiresValue(for: filePath) { diff --git a/FlyingFox/Sources/Handlers/FileHTTPHandler.swift b/FlyingFox/Sources/Handlers/FileHTTPHandler.swift index 1f72f47..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) { diff --git a/FlyingFox/Tests/HTTPConnectionTests.swift b/FlyingFox/Tests/HTTPConnectionTests.swift index 1b475ac..c41494e 100644 --- a/FlyingFox/Tests/HTTPConnectionTests.swift +++ b/FlyingFox/Tests/HTTPConnectionTests.swift @@ -105,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/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()