Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions FlyingFox/Sources/HTTPCacheControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 {
}
Expand Down
6 changes: 6 additions & 0 deletions FlyingFox/Sources/HTTPConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions FlyingFox/Sources/HTTPDate.swift
Original file line number Diff line number Diff line change
@@ -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
}()
}
2 changes: 1 addition & 1 deletion FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion FlyingFox/Sources/Handlers/FileHTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 28 additions & 1 deletion FlyingFox/Tests/HTTPConnectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
46 changes: 46 additions & 0 deletions FlyingFox/Tests/HTTPDateTests.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
53 changes: 53 additions & 0 deletions FlyingFox/Tests/HTTPServerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading