diff --git a/Package.swift b/Package.swift index 4aa4895ab..66b31cbaa 100644 --- a/Package.swift +++ b/Package.swift @@ -29,9 +29,11 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", from: "1.4.0"), ], targets: [ + .target(name: "CAsyncHTTPClient"), .target( name: "AsyncHTTPClient", dependencies: [ + .target(name: "CAsyncHTTPClient"), .product(name: "NIO", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), diff --git a/Sources/AsyncHTTPClient/FoundationExtensions.swift b/Sources/AsyncHTTPClient/FoundationExtensions.swift new file mode 100644 index 000000000..9ae258052 --- /dev/null +++ b/Sources/AsyncHTTPClient/FoundationExtensions.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2018-2021 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// Extensions which provide better ergonomics when using Foundation types, +// or by using Foundation APIs. + +import Foundation + +extension HTTPClient.Cookie { + /// The cookie's expiration date. + public var expires: Date? { + get { + expires_timestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) } + } + set { + expires_timestamp = newValue.map { Int64($0.timeIntervalSince1970) } + } + } + + /// Create HTTP cookie. + /// + /// - parameters: + /// - name: The name of the cookie. + /// - value: The cookie's string value. + /// - path: The cookie's path. + /// - domain: The domain of the cookie, defaults to nil. + /// - expires: The cookie's expiration date, defaults to nil. + /// - maxAge: The cookie's age in seconds, defaults to nil. + /// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false. + /// - secure: Whether this cookie should only be sent using secure channels, defaults to false. + public init(name: String, value: String, path: String = "/", domain: String? = nil, expires: Date? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) { + // FIXME: This should be failable and validate the inputs + // (for example, checking that the strings are ASCII, path begins with "/", domain is not empty, etc). + self.init( + name: name, + value: value, + path: path, + domain: domain, + expires_timestamp: expires.map { Int64($0.timeIntervalSince1970) }, + maxAge: maxAge, + httpOnly: httpOnly, + secure: secure + ) + } +} diff --git a/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift b/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift index 4a83d12a9..75fc28de4 100644 --- a/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift +++ b/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift @@ -12,8 +12,13 @@ // //===----------------------------------------------------------------------===// -import Foundation import NIOHTTP1 +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +import CAsyncHTTPClient extension HTTPClient { /// A representation of an HTTP cookie. @@ -26,8 +31,8 @@ extension HTTPClient { public var path: String /// The domain of the cookie. public var domain: String? - /// The cookie's expiration date. - public var expires: Date? + /// The cookie's expiration date, as a number of seconds since the Unix epoch. + var expires_timestamp: Int64? /// The cookie's age in seconds. public var maxAge: Int? /// Whether the cookie should only be sent to HTTP servers. @@ -42,79 +47,72 @@ extension HTTPClient { /// - defaultDomain: Default domain to use if cookie was sent without one. /// - returns: nil if the header is invalid. public init?(header: String, defaultDomain: String) { - let components = header.components(separatedBy: ";").map { - $0.trimmingCharacters(in: .whitespaces) - } - - if components.isEmpty { + // The parsing of "Set-Cookie" headers is defined by Section 5.2, RFC-6265: + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2 + var components = header.utf8.split(separator: UInt8(ascii: ";"), omittingEmptySubsequences: false)[...] + guard let keyValuePair = components.popFirst()?.trimmingASCIISpaces() else { return nil } - - let nameAndValue = components[0].split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map { - $0.trimmingCharacters(in: .whitespaces) - } - - guard nameAndValue.count == 2 else { + guard let (trimmedName, trimmedValue) = keyValuePair.parseKeyValuePair() else { return nil } - - self.name = nameAndValue[0] - self.value = nameAndValue[1].omittingQuotes() - - guard !self.name.isEmpty else { + guard !trimmedName.isEmpty else { return nil } - self.path = "/" - self.domain = defaultDomain - self.expires = nil + self.name = String(aligningUTF8: trimmedName) + self.value = String(aligningUTF8: trimmedValue.trimmingPairedASCIIQuote()) + self.expires_timestamp = nil self.maxAge = nil self.httpOnly = false self.secure = false - for component in components[1...] { - switch self.parseComponent(component) { - case (nil, nil): - continue - case ("path", .some(let value)): - self.path = value - case ("domain", .some(let value)): - self.domain = value - case ("expires", let value): - guard let value = value else { + var parsedPath: String.UTF8View.SubSequence? + var parsedDomain: String.UTF8View.SubSequence? + + for component in components { + switch component.parseCookieComponent() { + case ("path", let value)?: + // Unlike other values, unspecified, empty, and invalid paths reset to the default path. + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4 + guard let value = value, value.first == UInt8(ascii: "/") else { + parsedPath = nil continue } - - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US") - formatter.timeZone = TimeZone(identifier: "GMT") - - formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z" - if let date = formatter.date(from: value) { - self.expires = date + parsedPath = value + case ("domain", let value)?: + guard var value = value, !value.isEmpty else { continue } - - formatter.dateFormat = "EEE, dd-MMM-yy HH:mm:ss z" - if let date = formatter.date(from: value) { - self.expires = date + if value.first == UInt8(ascii: ".") { + value.removeFirst() + } + guard !value.isEmpty else { + parsedDomain = nil continue } - - formatter.dateFormat = "EEE MMM d hh:mm:s yyyy" - if let date = formatter.date(from: value) { - self.expires = date + parsedDomain = value + case ("expires", let value)?: + guard let value = value, let timestamp = parseCookieTime(value) else { + continue } - case ("max-age", let value): - self.maxAge = value.flatMap(Int.init) - case ("secure", nil): + self.expires_timestamp = timestamp + case ("max-age", let value)?: + guard let value = value, let age = Int(Substring(value)) else { + continue + } + self.maxAge = age + case ("secure", _)?: self.secure = true - case ("httponly", nil): + case ("httponly", _)?: self.httpOnly = true default: continue } } + + self.domain = parsedDomain.map { Substring($0).lowercased() } ?? defaultDomain.lowercased() + self.path = parsedPath.map { String(aligningUTF8: $0) } ?? "/" } /// Create HTTP cookie. @@ -124,51 +122,114 @@ extension HTTPClient { /// - value: The cookie's string value. /// - path: The cookie's path. /// - domain: The domain of the cookie, defaults to nil. - /// - expires: The cookie's expiration date, defaults to nil. + /// - expires_timestamp: The cookie's expiration date, as a number of seconds since the Unix epoch. defaults to nil. /// - maxAge: The cookie's age in seconds, defaults to nil. /// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false. /// - secure: Whether this cookie should only be sent using secure channels, defaults to false. - public init(name: String, value: String, path: String = "/", domain: String? = nil, expires: Date? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) { + internal init(name: String, value: String, path: String = "/", domain: String? = nil, expires_timestamp: Int64? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) { self.name = name self.value = value self.path = path self.domain = domain - self.expires = expires + self.expires_timestamp = expires_timestamp self.maxAge = maxAge self.httpOnly = httpOnly self.secure = secure } + } +} - func parseComponent(_ component: String) -> (String?, String?) { - let nameAndValue = component.split(separator: "=", maxSplits: 1).map { - $0.trimmingCharacters(in: .whitespaces) - } - if nameAndValue.count == 2 { - return (nameAndValue[0].lowercased(), nameAndValue[1]) - } else if nameAndValue.count == 1 { - return (nameAndValue[0].lowercased(), nil) - } - return (nil, nil) - } +extension HTTPClient.Response { + /// List of HTTP cookies returned by the server. + public var cookies: [HTTPClient.Cookie] { + return self.headers["set-cookie"].compactMap { HTTPClient.Cookie(header: $0, defaultDomain: self.host) } } } extension String { - fileprivate func omittingQuotes() -> String { - let dquote = "\"" - if !hasPrefix(dquote) || !hasSuffix(dquote) { - return self + /// Creates a String from a slice of UTF8 code-units, aligning the bounds to unicode scalar boundaries if needed. + fileprivate init(aligningUTF8 utf8Slice: String.UTF8View.SubSequence) { + self.init(Substring(utf8Slice)) + } +} + +extension String.UTF8View.SubSequence { + fileprivate func trimmingASCIISpaces() -> SubSequence { + guard let start = self.firstIndex(where: { $0 != UInt8(ascii: " ") }) else { + return self[self.endIndex.. SubSequence { + let quoteChar = UInt8(ascii: "\"") + var trimmed = self + if trimmed.popFirst() == quoteChar && trimmed.popLast() == quoteChar { + return trimmed + } + return self + } + + /// Splits this collection in to a key and value at the first ASCII '=' character. + /// Both the key and value are trimmed of ASCII spaces. + fileprivate func parseKeyValuePair() -> (key: SubSequence, value: SubSequence)? { + guard let keyValueSeparator = self.firstIndex(of: UInt8(ascii: "=")) else { + return nil } + let trimmedName = self[.. (key: String, value: SubSequence?)? { + let (trimmedName, trimmedValue) = self.parseKeyValuePair() ?? (self.trimmingASCIISpaces(), nil) + guard !trimmedName.isEmpty else { + return nil + } + return (Substring(trimmedName).lowercased(), trimmedValue) } } -extension HTTPClient.Response { - /// List of HTTP cookies returned by the server. - public var cookies: [HTTPClient.Cookie] { - return self.headers["set-cookie"].compactMap { HTTPClient.Cookie(header: $0, defaultDomain: self.host) } +private let posixLocale: UnsafeMutableRawPointer = { + // All POSIX systems must provide a "POSIX" locale, and its date/time formats are US English. + // https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap07.html#tag_07_03_05 + let _posixLocale = newlocale(LC_TIME_MASK | LC_NUMERIC_MASK, "POSIX", nil)! + return UnsafeMutableRawPointer(_posixLocale) +}() + +private func parseTimestamp(_ utf8: String.UTF8View.SubSequence, format: String) -> tm? { + var timeComponents = tm() + let success = Substring(utf8).withCString { cString in + swiftahc_cshims_strptime_l(cString, format, &timeComponents, posixLocale) + } + return success ? timeComponents : nil +} + +private func parseCookieTime(_ timestampUTF8: String.UTF8View.SubSequence) -> Int64? { + if timestampUTF8.contains(where: { $0 < 0x20 /* Control characters */ || $0 == 0x7F /* DEL */ }) { + return nil + } + var timestampUTF8 = timestampUTF8 + if timestampUTF8.hasSuffix("GMT".utf8) { + let timezoneStart = timestampUTF8.index(timestampUTF8.endIndex, offsetBy: -3) + timestampUTF8 = timestampUTF8[.. Void) { assert({ body(); return true }()) } + +extension BidirectionalCollection where Element: Equatable { + /// Returns a Boolean value indicating whether the collection ends with the specified suffix. + /// + /// If `suffix` is empty, this function returns `true`. + /// If all elements of the collections are equal, this function also returns `true`. + func hasSuffix(_ suffix: Suffix) -> Bool where Suffix: BidirectionalCollection, Suffix.Element == Element { + var ourIdx = self.endIndex + var suffixIdx = suffix.endIndex + while ourIdx > self.startIndex, suffixIdx > suffix.startIndex { + self.formIndex(before: &ourIdx) + suffix.formIndex(before: &suffixIdx) + guard self[ourIdx] == suffix[suffixIdx] else { return false } + } + guard suffixIdx == suffix.startIndex else { + return false // Exhausted self, but 'suffix' has elements remaining. + } + return true // Exhausted 'other' without finding a mismatch. + } +} diff --git a/Sources/CAsyncHTTPClient/CAsyncHTTPClient.c b/Sources/CAsyncHTTPClient/CAsyncHTTPClient.c new file mode 100644 index 000000000..2a09d04c9 --- /dev/null +++ b/Sources/CAsyncHTTPClient/CAsyncHTTPClient.c @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2018-2021 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if __APPLE__ + #include +#elif __linux__ + #define _GNU_SOURCE + #include +#endif + +#include +#include + +bool swiftahc_cshims_strptime(const char * string, const char * format, struct tm * result) { + const char * firstNonProcessed = strptime(string, format, result); + if (firstNonProcessed) { + return *firstNonProcessed == 0; + } + return false; +} + +bool swiftahc_cshims_strptime_l(const char * string, const char * format, struct tm * result, void * locale) { + // The pointer cast is fine as long we make sure it really points to a locale_t. + const char * firstNonProcessed = strptime_l(string, format, result, (locale_t)locale); + if (firstNonProcessed) { + return *firstNonProcessed == 0; + } + return false; +} diff --git a/Sources/CAsyncHTTPClient/include/CAsyncHTTPClient.h b/Sources/CAsyncHTTPClient/include/CAsyncHTTPClient.h new file mode 100644 index 000000000..00aa87bb6 --- /dev/null +++ b/Sources/CAsyncHTTPClient/include/CAsyncHTTPClient.h @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2018-2021 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#ifndef CASYNC_HTTP_CLIENT_H +#define CASYNC_HTTP_CLIENT_H + +#include +#include + +bool swiftahc_cshims_strptime( + const char * _Nonnull input, + const char * _Nonnull format, + struct tm * _Nonnull result +); + +bool swiftahc_cshims_strptime_l( + const char * _Nonnull input, + const char * _Nonnull format, + struct tm * _Nonnull result, + void * _Nullable locale +); + +#endif // CASYNC_HTTP_CLIENT_H diff --git a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift index d686b1bc0..7ecf54d4d 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift @@ -30,8 +30,15 @@ extension HTTPClientCookieTests { ("testCookieDefaults", testCookieDefaults), ("testCookieInit", testCookieInit), ("testMalformedCookies", testMalformedCookies), + ("testExpires", testExpires), + ("testMaxAge", testMaxAge), + ("testDomain", testDomain), + ("testPath", testPath), + ("testSecure", testSecure), + ("testHttpOnly", testHttpOnly), ("testCookieExpiresDateParsing", testCookieExpiresDateParsing), ("testQuotedCookies", testQuotedCookies), + ("testCookieExpiresDateParsingWithNonEnglishLocale", testCookieExpiresDateParsingWithNonEnglishLocale), ] } } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift index cfa9a37ca..8b4c9adf6 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift @@ -13,13 +13,17 @@ //===----------------------------------------------------------------------===// import AsyncHTTPClient +import CAsyncHTTPClient import Foundation import XCTest class HTTPClientCookieTests: XCTestCase { func testCookie() { - let v = "key=value; PaTh=/path; DoMaIn=example.com; eXpIRes=Wed, 21 Oct 2015 07:28:00 GMT; max-AGE=42; seCURE; HTTPOnly" - let c = HTTPClient.Cookie(header: v, defaultDomain: "exampe.org")! + let v = "key=value; PaTh=/path; DoMaIn=EXampLE.CoM; eXpIRes=Wed, 21 Oct 2015 07:28:00 GMT; max-AGE=42; seCURE; HTTPOnly" + guard let c = HTTPClient.Cookie(header: v, defaultDomain: "exAMPle.cOm") else { + XCTFail("Failed to parse cookie") + return + } XCTAssertEqual("key", c.name) XCTAssertEqual("value", c.value) XCTAssertEqual("/path", c.path) @@ -32,11 +36,14 @@ class HTTPClientCookieTests: XCTestCase { func testEmptyValueCookie() { let v = "cookieValue=; Path=/" - let c = HTTPClient.Cookie(header: v, defaultDomain: "exampe.org")! + guard let c = HTTPClient.Cookie(header: v, defaultDomain: "example.com") else { + XCTFail("Failed to parse cookie") + return + } XCTAssertEqual("cookieValue", c.name) XCTAssertEqual("", c.value) XCTAssertEqual("/", c.path) - XCTAssertEqual("exampe.org", c.domain) + XCTAssertEqual("example.com", c.domain) XCTAssertNil(c.expires) XCTAssertNil(c.maxAge) XCTAssertFalse(c.httpOnly) @@ -45,11 +52,14 @@ class HTTPClientCookieTests: XCTestCase { func testCookieDefaults() { let v = "key=value" - let c = HTTPClient.Cookie(header: v, defaultDomain: "example.org")! + guard let c = HTTPClient.Cookie(header: v, defaultDomain: "exAMPle.com") else { + XCTFail("Failed to parse cookie") + return + } XCTAssertEqual("key", c.name) XCTAssertEqual("value", c.value) XCTAssertEqual("/", c.path) - XCTAssertEqual("example.org", c.domain) + XCTAssertEqual("example.com", c.domain) XCTAssertNil(c.expires) XCTAssertNil(c.maxAge) XCTAssertFalse(c.httpOnly) @@ -70,6 +80,7 @@ class HTTPClientCookieTests: XCTestCase { func testMalformedCookies() { XCTAssertNil(HTTPClient.Cookie(header: "", defaultDomain: "exampe.org")) + XCTAssertNil(HTTPClient.Cookie(header: "name", defaultDomain: "exampe.org")) XCTAssertNil(HTTPClient.Cookie(header: ";;", defaultDomain: "exampe.org")) XCTAssertNil(HTTPClient.Cookie(header: "name;;", defaultDomain: "exampe.org")) XCTAssertNotNil(HTTPClient.Cookie(header: "name=;;", defaultDomain: "exampe.org")) @@ -83,26 +94,385 @@ class HTTPClientCookieTests: XCTestCase { XCTAssertNil(HTTPClient.Cookie(header: "=value;", defaultDomain: "exampe.org")) } + func testExpires() { + // Empty values, and unrecognized timestamps, are ignored. + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1 + var c = HTTPClient.Cookie(header: "key=value; expires=", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertNil(c?.expires) + + c = HTTPClient.Cookie(header: "key=value; expires", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertNil(c?.expires) + + c = HTTPClient.Cookie(header: "key=value; expires=foo", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertNil(c?.expires) + + c = HTTPClient.Cookie(header: "key=value; expires=04/01/2022", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertNil(c?.expires) + + // Later values override earlier values, except if they are ignored. + c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37 GMT; expires=04/01/2022", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + + c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37 GMT; expires=", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + + c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37 GMT; expires", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + + // For more comprehensive tests of the various timestamp formats, see: `testCookieExpiresDateParsing`. + } + + func testMaxAge() { + // Empty values, and values containing non-digits, are ignored. + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2 + var c = HTTPClient.Cookie(header: "key=value; max-age=", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertNil(c?.maxAge) + + c = HTTPClient.Cookie(header: "key=value; max-age", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertNil(c?.maxAge) + + c = HTTPClient.Cookie(header: "key=value; max-age=foo", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertNil(c?.maxAge) + + c = HTTPClient.Cookie(header: "key=value; max-age=123foo", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertNil(c?.maxAge) + + // Later values override earlier values, except if they are ignored. + c = HTTPClient.Cookie(header: "key=value; max-age=123; max-age=456baz", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(123, c?.maxAge) + + c = HTTPClient.Cookie(header: "key=value; max-age=-123; max-age=", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(-123, c?.maxAge) + + c = HTTPClient.Cookie(header: "key=value; max-age=123; max-age", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(123, c?.maxAge) + } + + func testDomain() { + // Empty domains should be ignored. + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3 + var c = HTTPClient.Cookie(header: "key=value; domain=", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("example.com", c?.domain) + + c = HTTPClient.Cookie(header: "key=value; domain", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("example.com", c?.domain) + + // A single leading dot is stripped. + c = HTTPClient.Cookie(header: "key=value; domain=.foo", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("foo", c?.domain) + + c = HTTPClient.Cookie(header: "key=value; domain=..foo", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(".foo", c?.domain) + + // RFC-6562 checks for empty values before stipping the dot (resulting in an empty domain), + // but later, empty domains are placed by the canonicalized request host. + // We use the default domain as the request host. + c = HTTPClient.Cookie(header: "key=value; domain=.", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("example.com", c?.domain) + + // Later values override earlier values, except if they are ignored. + c = HTTPClient.Cookie(header: "key=value; domain=foo; domain=bar", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("bar", c?.domain) + + c = HTTPClient.Cookie(header: "key=value; domain=foo; domain=", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("foo", c?.domain) + + c = HTTPClient.Cookie(header: "key=value; domain=foo; domain", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("foo", c?.domain) + + c = HTTPClient.Cookie(header: "key=value; domain=foo; domain=.", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("example.com", c?.domain) + + // The domain (including the defaultDomain parameter) should be normalized to lowercase. + c = HTTPClient.Cookie(header: "key=value; domain=FOO; domain", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("foo", c?.domain) + + c = HTTPClient.Cookie(header: "key=value; domain=; domain", defaultDomain: "EXAMPLE.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("example.com", c?.domain) + } + + func testPath() { + // An empty path, or path which does not begin with a "/", is considered the default path. + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4 + var c = HTTPClient.Cookie(header: "key=value; path=", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("/", c?.path) + + c = HTTPClient.Cookie(header: "key=value; path", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("/", c?.path) + + c = HTTPClient.Cookie(header: "key=value; path=foo", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("/", c?.path) + + // Later path values override earlier values, even if the later value is considered the default path. + c = HTTPClient.Cookie(header: "key=value; path=/abc; path=/foo", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("/foo", c?.path) + + c = HTTPClient.Cookie(header: "key=value; path=/abc; path=foo", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("/", c?.path) + + c = HTTPClient.Cookie(header: "key=value; path=/abc; path", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual("/", c?.path) + } + + func testSecure() { + // If the cookie contains a key called "secure" (case-insensitive), the secure flag is set. + // Regardless of its value. + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5 + var c = HTTPClient.Cookie(header: "key=value; secure=", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.secure) + + c = HTTPClient.Cookie(header: "key=value; secure", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.secure) + + c = HTTPClient.Cookie(header: "key=value; secure=0", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.secure) + + c = HTTPClient.Cookie(header: "key=value; secure=false", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.secure) + + c = HTTPClient.Cookie(header: "key=value; secure=no", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.secure) + } + + func testHttpOnly() { + // If the cookie contains a key called "httponly" (case-insensitive), the http-only flag is set. + // Regardless of its value. + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6 + var c = HTTPClient.Cookie(header: "key=value; httponly=", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.httpOnly) + + c = HTTPClient.Cookie(header: "key=value; httponly", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.httpOnly) + + c = HTTPClient.Cookie(header: "key=value; httponly=0", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.httpOnly) + + c = HTTPClient.Cookie(header: "key=value; httponly=false", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.httpOnly) + + c = HTTPClient.Cookie(header: "key=value; httponly=no", defaultDomain: "example.com") + XCTAssertEqual("key", c?.name) + XCTAssertEqual("value", c?.value) + XCTAssertEqual(true, c?.httpOnly) + } + func testCookieExpiresDateParsing() { - var c = HTTPClient.Cookie(header: "key=value; eXpIRes=Sun, 06 Nov 1994 08:49:37 GMT;", defaultDomain: "example.org")! - XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c.expires) + let domain = "example.org" + + // Regular formats. + var c = HTTPClient.Cookie(header: "key=value; eXpIRes=Sun, 06 Nov 1994 08:49:37 GMT;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; eXpIRes=Sunday, 06-Nov-94 08:49:37 GMT;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; eXpIRes=Sun Nov 6 08:49:37 1994;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + + // GMT is implicit. + // Formats which typically include it may omit it; formats which typically omit it may include it. + c = HTTPClient.Cookie(header: "key=value; expires=Sun, 06 Nov 1994 08:49:37;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun Nov 6 08:49:37 1994 GMT;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + + // If GMT is explicit, it must be separated from the timestamp by at least one space. + c = HTTPClient.Cookie(header: "key=value; expires=Sun, 06 Nov 1994 08:49:37GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun Nov 6 08:49:37 1994GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) - c = HTTPClient.Cookie(header: "key=value; eXpIRes=Sunday, 06-Nov-94 08:49:37 GMT;", defaultDomain: "example.org")! - XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c.expires) + // Where space are required, any number of spaces are okay. + c = HTTPClient.Cookie(header: "key=value; expires=Sun, 06 Nov 1994 08:49:37;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires= Sun Nov 6 08:49:37 1994;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun, 06 Nov 1994 08:49:37 GMT;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37 GMT;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun Nov 6 08:49:37 1994 GMT;", defaultDomain: domain) + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) - c = HTTPClient.Cookie(header: "key=value; eXpIRes=Sun Nov 6 08:49:37 1994;", defaultDomain: "example.org")! - XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c.expires) + // Where spaces are required, tabs and newlines are not okay. + c = HTTPClient.Cookie(header: "key=value; expires=Sun,\t06 Nov 1994 08:49:37 GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun,\n06 Nov 1994 08:49:37 GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun, 06 Nov 1994 08:49:37\tGMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun, 06 Nov 1994 08:49:37\nGMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + + // Spaces are only allowed in particular locations. + c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06- Nov-94 08:49:37;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires= Sun Nov 6 08:4 9:37 1994;", defaultDomain: domain) + XCTAssertNil(c?.expires) + + // Incorrect comma placement. + c = HTTPClient.Cookie(header: "key=value; expires=Sun 06 Nov 1994 08:49:37 GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sunday 06-Nov-94 08:49:37 GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun, Nov 6 08:49:37 1994 GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + + // Incorrect delimiters. + c = HTTPClient.Cookie(header: "key=value; expires=Sunday 06/Nov/94 08:49:37 GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun, Nov 6 08-49-37 1994 GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + + // Non-GMT timezones are rejected. + c = HTTPClient.Cookie(header: "key=value; expires=Sun, 06 Nov 1994 08:49:37 BST;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37 PST;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=Sun Nov 6 08:49:37 1994 CET;", defaultDomain: domain) + XCTAssertNil(c?.expires) + + c = HTTPClient.Cookie(header: "key=value; expires=GMT;", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=\" GMT\";", defaultDomain: domain) + XCTAssertNil(c?.expires) + c = HTTPClient.Cookie(header: "key=value; expires=CET;", defaultDomain: domain) + XCTAssertNil(c?.expires) } func testQuotedCookies() { - var c = HTTPClient.Cookie(header: "key=\"value\"", defaultDomain: "example.org")! - XCTAssertEqual("value", c.value) + var c = HTTPClient.Cookie(header: "key=\"value\"", defaultDomain: "example.org") + XCTAssertEqual("value", c?.value) - c = HTTPClient.Cookie(header: "key=\"value\"; Path=/path", defaultDomain: "example.org")! - XCTAssertEqual("value", c.value) - XCTAssertEqual("/path", c.path) + c = HTTPClient.Cookie(header: "key=\"value\"; Path=/path", defaultDomain: "example.org") + XCTAssertEqual("value", c?.value) + XCTAssertEqual("/path", c?.path) - c = HTTPClient.Cookie(header: "key=\"\"", defaultDomain: "example.org")! - XCTAssertEqual("", c.value) + c = HTTPClient.Cookie(header: "key=\"\"", defaultDomain: "example.org") + XCTAssertEqual("", c?.value) + + // Spaces inside paired quotes are not trimmed. + c = HTTPClient.Cookie(header: "key=\" abc \"", defaultDomain: "example.org") + XCTAssertEqual(" abc ", c?.value) + + c = HTTPClient.Cookie(header: "key=\" \"", defaultDomain: "example.org") + XCTAssertEqual(" ", c?.value) + + // Unpaired quote at start of value. + c = HTTPClient.Cookie(header: "key=\"abc", defaultDomain: "example.org") + XCTAssertEqual("\"abc", c?.value) + + // Unpaired quote in the middle of the value. + c = HTTPClient.Cookie(header: "key=ab\"c", defaultDomain: "example.org") + XCTAssertEqual("ab\"c", c?.value) + + // Unpaired quote at the end of the value. + c = HTTPClient.Cookie(header: "key=abc\"", defaultDomain: "example.org") + XCTAssertEqual("abc\"", c?.value) + } + + func testCookieExpiresDateParsingWithNonEnglishLocale() throws { + try withCLocaleSetToGerman { + // Check that we are using a German C locale. + var localeCheck = tm() + guard swiftahc_cshims_strptime("Freitag Februar", "%a %b", &localeCheck) else { + throw XCTSkip("Unable to set locale") + } + // These values are zero-based 🙄 + try XCTSkipIf(localeCheck.tm_wday != 5, "Unable to set locale") + try XCTSkipIf(localeCheck.tm_mon != 1, "Unable to set locale") + + // Cookie parsing should be independent of C locale. + var c = HTTPClient.Cookie(header: "key=value; eXpIRes=Sunday, 06-Nov-94 08:49:37 GMT;", defaultDomain: "example.org") + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; eXpIRes=Sun Nov 6 08:49:37 1994;", defaultDomain: "example.org")! + XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires) + c = HTTPClient.Cookie(header: "key=value; eXpIRes=Sonntag, 06-Nov-94 08:49:37 GMT;", defaultDomain: "example.org")! + XCTAssertNil(c?.expires) + } } } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientInternalTests+XCTest.swift b/Tests/AsyncHTTPClientTests/HTTPClientInternalTests+XCTest.swift index 40b792db6..3be2c79a6 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientInternalTests+XCTest.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientInternalTests+XCTest.swift @@ -35,6 +35,7 @@ extension HTTPClientInternalTests { ("testTaskPromiseBoundToEL", testTaskPromiseBoundToEL), ("testConnectErrorCalloutOnCorrectEL", testConnectErrorCalloutOnCorrectEL), ("testInternalRequestURI", testInternalRequestURI), + ("testHasSuffix", testHasSuffix), ] } } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift index 349f44e20..eb8d523bb 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift @@ -519,4 +519,37 @@ class HTTPClientInternalTests: XCTestCase { )) XCTAssertEqual(request11.deconstructedURL.uri, "/foo/bar?baz") } + + func testHasSuffix() { + // Simple collection. + do { + let elements = (0...10) + XCTAssertTrue(elements.hasSuffix([8, 9, 10])) + XCTAssertTrue(elements.hasSuffix([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])) + XCTAssertTrue(elements.hasSuffix([10])) + XCTAssertTrue(elements.hasSuffix([])) + + XCTAssertFalse(elements.hasSuffix([8, 9, 10, 11])) + XCTAssertFalse(elements.hasSuffix([0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])) + XCTAssertFalse(elements.hasSuffix([9])) + XCTAssertFalse(elements.hasSuffix([0])) + } + // Single-element collection. + do { + let elements = [99] + XCTAssertTrue(elements.hasSuffix(["99"].lazy.map { Int($0)! })) + XCTAssertTrue(elements.hasSuffix([])) + XCTAssertFalse(elements.hasSuffix([98, 99])) + XCTAssertFalse(elements.hasSuffix([99, 99])) + XCTAssertFalse(elements.hasSuffix([99, 100])) + } + // Empty collection. + do { + let elements: Array = [] + XCTAssertTrue(elements.hasSuffix([])) + XCTAssertFalse(elements.hasSuffix([0])) + XCTAssertFalse(elements.hasSuffix([42])) + XCTAssertFalse(elements.hasSuffix([0, 0, 0])) + } + } } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift index 6a1b78e4a..230c91a2b 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift @@ -27,6 +27,11 @@ import NIOSSL import NIOTLS import NIOTransportServices import XCTest +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif /// Are we testing NIO Transport services func isTestingNIOTS() -> Bool { @@ -59,6 +64,24 @@ let canBindIPv6Loopback: Bool = { return didBind }() +/// Runs the given block in the context of a non-English C locale (in this case, German). +/// Throws an XCTSkip error if the locale is not supported by the system. +func withCLocaleSetToGerman(_ body: () throws -> Void) throws { + guard let germanLocale = newlocale(LC_TIME_MASK | LC_NUMERIC_MASK, "de_DE", nil) else { + if errno == ENOENT { + throw XCTSkip("System does not support locale de_DE") + } else { + XCTFail("Failed to create locale de_DE") + return + } + } + defer { freelocale(germanLocale) } + + let oldLocale = uselocale(germanLocale) + defer { _ = uselocale(oldLocale) } + try body() +} + class TestHTTPDelegate: HTTPClientResponseDelegate { typealias Response = Void