From 273ebad69fe87e3e312d8dae57acd66f179aca1b Mon Sep 17 00:00:00 2001 From: Karl <5254025+karwa@users.noreply.github.com> Date: Tue, 4 Jan 2022 16:23:55 +0100 Subject: [PATCH 1/3] Redo HTTP cookie parsing using strptime --- Package.swift | 2 + .../FoundationExtensions.swift | 55 +++++ .../HTTPClient+HTTPCookie.swift | 203 +++++++++++------- Sources/AsyncHTTPClient/Utils.swift | 20 ++ Sources/CAsyncHTTPClient/CAsyncHTTPClient.c | 40 ++++ .../include/CAsyncHTTPClient.h | 34 +++ .../HTTPClientCookieTests+XCTest.swift | 1 + .../HTTPClientCookieTests.swift | 164 ++++++++++++-- .../HTTPClientInternalTests+XCTest.swift | 1 + .../HTTPClientInternalTests.swift | 33 +++ .../HTTPClientTestUtils.swift | 23 ++ 11 files changed, 475 insertions(+), 101 deletions(-) create mode 100644 Sources/AsyncHTTPClient/FoundationExtensions.swift create mode 100644 Sources/CAsyncHTTPClient/CAsyncHTTPClient.c create mode 100644 Sources/CAsyncHTTPClient/include/CAsyncHTTPClient.h 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..92bed5684 --- /dev/null +++ b/Sources/AsyncHTTPClient/FoundationExtensions.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// +// 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 (for example, if the strings contain non-ASCII characters). + 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..2b190736b 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,74 +47,40 @@ 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 { + 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 } - + // FIXME: The parsed values should be validated to ensure they only contain ASCII characters allowed by RFC-6265. + // https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1 + self.name = String(utf8Slice: trimmedName, in: header) + self.value = String(utf8Slice: trimmedValue.trimmingPairedASCIIQuote(), in: header) self.path = "/" self.domain = defaultDomain - self.expires = nil + 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 { - 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 - continue - } - - formatter.dateFormat = "EEE, dd-MMM-yy HH:mm:ss z" - if let date = formatter.date(from: value) { - self.expires = date - continue - } - - formatter.dateFormat = "EEE MMM d hh:mm:s yyyy" - if let date = formatter.date(from: value) { - self.expires = date - } - case ("max-age", let value): - self.maxAge = value.flatMap(Int.init) - case ("secure", nil): + for component in components { + switch component.parseCookieComponent() { + case ("path", .some(let value))?: + self.path = String(utf8Slice: value, in: header) + case ("domain", .some(let value))?: + self.domain = String(utf8Slice: value, in: header) + case ("expires", .some(let value))?: + self.expires_timestamp = parseCookieTime(value, in: header) + case ("max-age", .some(let value))?: + self.maxAge = Int(Substring(value)) + case ("secure", nil)?: self.secure = true - case ("httponly", nil): + case ("httponly", nil)?: self.httpOnly = true default: continue @@ -124,51 +95,117 @@ 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(utf8Slice: String.UTF8View.SubSequence, in base: String) { + self.init(base[utf8Slice.startIndex.. 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(_ string: String, format: String) -> tm? { + var timeComponents = tm() + guard swiftahc_cshims_strptime_l(string, format, &timeComponents, posixLocale) else { + return nil + } + return timeComponents +} + +private func parseCookieTime(_ timestampUTF8: String.UTF8View.SubSequence, in header: String) -> Int64? { + if timestampUTF8.contains(where: { $0 < 0x20 /* Control characters */ || $0 == 0x7F /* DEL */ }) { + return nil + } + let timestampString: String + if timestampUTF8.hasSuffix("GMT".utf8) { + let timezoneStart = timestampUTF8.index(timestampUTF8.endIndex, offsetBy: -3) + let trimmedTimestampUTF8 = 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..073b74cb7 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift @@ -32,6 +32,7 @@ extension HTTPClientCookieTests { ("testMalformedCookies", testMalformedCookies), ("testCookieExpiresDateParsing", testCookieExpiresDateParsing), ("testQuotedCookies", testQuotedCookies), + ("testCookieExpiresDateParsingWithNonEnglishLocale", testCookieExpiresDateParsingWithNonEnglishLocale), ] } } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift index cfa9a37ca..7eff557df 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")! + 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")) @@ -84,25 +95,142 @@ class HTTPClientCookieTests: XCTestCase { } 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) + + // 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=Sunday, 06-Nov-94 08:49:37 GMT;", 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) - 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) + // 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 From 3bebe58eb38d1478225c58e0e081c967f464160e Mon Sep 17 00:00:00 2001 From: Karl <5254025+karwa@users.noreply.github.com> Date: Tue, 4 Jan 2022 21:21:05 +0100 Subject: [PATCH 2/3] Make String(utf8Slice:from:) less ugly --- .../HTTPClient+HTTPCookie.swift | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift b/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift index 2b190736b..dddf423f7 100644 --- a/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift +++ b/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift @@ -57,10 +57,11 @@ extension HTTPClient { guard !trimmedName.isEmpty else { return nil } + // FIXME: The parsed values should be validated to ensure they only contain ASCII characters allowed by RFC-6265. // https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1 - self.name = String(utf8Slice: trimmedName, in: header) - self.value = String(utf8Slice: trimmedValue.trimmingPairedASCIIQuote(), in: header) + self.name = String(aligningUTF8: trimmedName) + self.value = String(aligningUTF8: trimmedValue.trimmingPairedASCIIQuote()) self.path = "/" self.domain = defaultDomain self.expires_timestamp = nil @@ -71,11 +72,11 @@ extension HTTPClient { for component in components { switch component.parseCookieComponent() { case ("path", .some(let value))?: - self.path = String(utf8Slice: value, in: header) + self.path = String(aligningUTF8: value) case ("domain", .some(let value))?: - self.domain = String(utf8Slice: value, in: header) + self.domain = String(aligningUTF8: value) case ("expires", .some(let value))?: - self.expires_timestamp = parseCookieTime(value, in: header) + self.expires_timestamp = parseCookieTime(value) case ("max-age", .some(let value))?: self.maxAge = Int(Substring(value)) case ("secure", nil)?: @@ -121,8 +122,8 @@ extension HTTPClient.Response { extension String { /// Creates a String from a slice of UTF8 code-units, aligning the bounds to unicode scalar boundaries if needed. - fileprivate init(utf8Slice: String.UTF8View.SubSequence, in base: String) { - self.init(base[utf8Slice.startIndex.. tm? { +private func parseTimestamp(_ utf8: String.UTF8View.SubSequence, format: String) -> tm? { var timeComponents = tm() - guard swiftahc_cshims_strptime_l(string, format, &timeComponents, posixLocale) else { - return nil + let success = Substring(utf8).withCString { cString in + swiftahc_cshims_strptime_l(cString, format, &timeComponents, posixLocale) } - return timeComponents + return success ? timeComponents : nil } -private func parseCookieTime(_ timestampUTF8: String.UTF8View.SubSequence, in header: String) -> Int64? { +private func parseCookieTime(_ timestampUTF8: String.UTF8View.SubSequence) -> Int64? { if timestampUTF8.contains(where: { $0 < 0x20 /* Control characters */ || $0 == 0x7F /* DEL */ }) { return nil } - let timestampString: String + var timestampUTF8 = timestampUTF8 if timestampUTF8.hasSuffix("GMT".utf8) { let timezoneStart = timestampUTF8.index(timestampUTF8.endIndex, offsetBy: -3) - let trimmedTimestampUTF8 = timestampUTF8[.. Date: Tue, 4 Jan 2022 20:39:19 +0100 Subject: [PATCH 3/3] Adjust cookie component parsing to better match RFC-6562 --- .../FoundationExtensions.swift | 3 +- .../HTTPClient+HTTPCookie.swift | 54 +++- .../HTTPClientCookieTests+XCTest.swift | 6 + .../HTTPClientCookieTests.swift | 248 +++++++++++++++++- 4 files changed, 293 insertions(+), 18 deletions(-) diff --git a/Sources/AsyncHTTPClient/FoundationExtensions.swift b/Sources/AsyncHTTPClient/FoundationExtensions.swift index 92bed5684..9ae258052 100644 --- a/Sources/AsyncHTTPClient/FoundationExtensions.swift +++ b/Sources/AsyncHTTPClient/FoundationExtensions.swift @@ -40,7 +40,8 @@ extension HTTPClient.Cookie { /// - 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 (for example, if the strings contain non-ASCII characters). + // 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, diff --git a/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift b/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift index dddf423f7..75fc28de4 100644 --- a/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift +++ b/Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift @@ -47,6 +47,8 @@ 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) { + // 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 @@ -58,35 +60,59 @@ extension HTTPClient { return nil } - // FIXME: The parsed values should be validated to ensure they only contain ASCII characters allowed by RFC-6265. - // https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1 self.name = String(aligningUTF8: trimmedName) self.value = String(aligningUTF8: trimmedValue.trimmingPairedASCIIQuote()) - self.path = "/" - self.domain = defaultDomain self.expires_timestamp = nil self.maxAge = nil self.httpOnly = false self.secure = false + var parsedPath: String.UTF8View.SubSequence? + var parsedDomain: String.UTF8View.SubSequence? + for component in components { switch component.parseCookieComponent() { - case ("path", .some(let value))?: - self.path = String(aligningUTF8: value) - case ("domain", .some(let value))?: - self.domain = String(aligningUTF8: value) - case ("expires", .some(let value))?: - self.expires_timestamp = parseCookieTime(value) - case ("max-age", .some(let value))?: - self.maxAge = Int(Substring(value)) - case ("secure", nil)?: + 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 + } + parsedPath = value + case ("domain", let value)?: + guard var value = value, !value.isEmpty else { + continue + } + if value.first == UInt8(ascii: ".") { + value.removeFirst() + } + guard !value.isEmpty else { + parsedDomain = nil + continue + } + parsedDomain = value + case ("expires", let value)?: + guard let value = value, let timestamp = parseCookieTime(value) else { + continue + } + 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. diff --git a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift index 073b74cb7..7ecf54d4d 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift @@ -30,6 +30,12 @@ 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 7eff557df..8b4c9adf6 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift @@ -19,8 +19,8 @@ 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" - guard let c = HTTPClient.Cookie(header: v, defaultDomain: "example.com") else { + 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 } @@ -52,7 +52,7 @@ class HTTPClientCookieTests: XCTestCase { func testCookieDefaults() { let v = "key=value" - guard let c = HTTPClient.Cookie(header: v, defaultDomain: "example.com") else { + guard let c = HTTPClient.Cookie(header: v, defaultDomain: "exAMPle.com") else { XCTFail("Failed to parse cookie") return } @@ -94,6 +94,248 @@ 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() { let domain = "example.org"