Skip to content

Commit d790ad4

Browse files
committed
Redo HTTP cookie parsing using strptime
1 parent a956e7b commit d790ad4

File tree

9 files changed

+286
-84
lines changed

9 files changed

+286
-84
lines changed

Package.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ let package = Package(
2929
.package(url: "https://github.com/apple/swift-log.git", from: "1.4.0"),
3030
],
3131
targets: [
32+
.target(name: "CShims"),
3233
.target(
3334
name: "AsyncHTTPClient",
3435
dependencies: [
36+
.target(name: "CShims"),
3537
.product(name: "NIO", package: "swift-nio"),
3638
.product(name: "NIOCore", package: "swift-nio"),
3739
.product(name: "NIOPosix", package: "swift-nio"),
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2018-2019 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
// Extensions which provide better ergonomics when using Foundation types,
16+
// or by using Foundation APIs.
17+
18+
import Foundation
19+
20+
extension HTTPClient.Cookie {
21+
/// The cookie's expiration date.
22+
public var expires: Date? {
23+
expires_timestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
24+
}
25+
26+
/// Create HTTP cookie.
27+
///
28+
/// - parameters:
29+
/// - name: The name of the cookie.
30+
/// - value: The cookie's string value.
31+
/// - path: The cookie's path.
32+
/// - domain: The domain of the cookie, defaults to nil.
33+
/// - expires: The cookie's expiration date, defaults to nil.
34+
/// - maxAge: The cookie's age in seconds, defaults to nil.
35+
/// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
36+
/// - secure: Whether this cookie should only be sent using secure channels, defaults to false.
37+
public init(name: String, value: String, path: String = "/", domain: String? = nil, expires: Date? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) {
38+
self.init(
39+
name: name,
40+
value: value,
41+
path: path,
42+
domain: domain,
43+
expires_timestamp: expires.map { Int64($0.timeIntervalSince1970) },
44+
maxAge: maxAge,
45+
httpOnly: httpOnly,
46+
secure: secure
47+
)
48+
}
49+
}

Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift

Lines changed: 96 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15-
import Foundation
1615
import NIOHTTP1
1716

1817
extension HTTPClient {
@@ -26,8 +25,8 @@ extension HTTPClient {
2625
public var path: String
2726
/// The domain of the cookie.
2827
public var domain: String?
29-
/// The cookie's expiration date.
30-
public var expires: Date?
28+
/// The cookie's expiration date, as a number of seconds since the Unix epoch.
29+
var expires_timestamp: Int64?
3130
/// The cookie's age in seconds.
3231
public var maxAge: Int?
3332
/// Whether the cookie should only be sent to HTTP servers.
@@ -42,38 +41,29 @@ extension HTTPClient {
4241
/// - defaultDomain: Default domain to use if cookie was sent without one.
4342
/// - returns: nil if the header is invalid.
4443
public init?(header: String, defaultDomain: String) {
45-
let components = header.components(separatedBy: ";").map {
46-
$0.trimmingCharacters(in: .whitespaces)
47-
}
44+
let components = header.split(separator: ";", omittingEmptySubsequences: false)
4845

49-
if components.isEmpty {
46+
guard let keyValuePair = components.first?.trimmingWhitespace() else {
5047
return nil
5148
}
52-
53-
let nameAndValue = components[0].split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map {
54-
$0.trimmingCharacters(in: .whitespaces)
55-
}
56-
57-
guard nameAndValue.count == 2 else {
49+
guard let (trimmedName, trimmedValue) = keyValuePair.parseKeyValuePair() else {
5850
return nil
5951
}
60-
61-
self.name = nameAndValue[0]
62-
self.value = nameAndValue[1].omittingQuotes()
63-
64-
guard !self.name.isEmpty else {
52+
guard !trimmedName.isEmpty else {
6553
return nil
6654
}
6755

56+
self.name = String(trimmedName)
57+
self.value = String(trimmedValue.omittingQuotes())
6858
self.path = "/"
6959
self.domain = defaultDomain
70-
self.expires = nil
60+
self.expires_timestamp = nil
7161
self.maxAge = nil
7262
self.httpOnly = false
7363
self.secure = false
7464

7565
for component in components[1...] {
76-
switch self.parseComponent(component) {
66+
switch component.parseCookieComponent() {
7767
case (nil, nil):
7868
continue
7969
case ("path", .some(let value)):
@@ -84,27 +74,7 @@ extension HTTPClient {
8474
guard let value = value else {
8575
continue
8676
}
87-
88-
let formatter = DateFormatter()
89-
formatter.locale = Locale(identifier: "en_US")
90-
formatter.timeZone = TimeZone(identifier: "GMT")
91-
92-
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z"
93-
if let date = formatter.date(from: value) {
94-
self.expires = date
95-
continue
96-
}
97-
98-
formatter.dateFormat = "EEE, dd-MMM-yy HH:mm:ss z"
99-
if let date = formatter.date(from: value) {
100-
self.expires = date
101-
continue
102-
}
103-
104-
formatter.dateFormat = "EEE MMM d hh:mm:s yyyy"
105-
if let date = formatter.date(from: value) {
106-
self.expires = date
107-
}
77+
self.expires_timestamp = parseCookieTime(value)
10878
case ("max-age", let value):
10979
self.maxAge = value.flatMap(Int.init)
11080
case ("secure", nil):
@@ -124,51 +94,111 @@ extension HTTPClient {
12494
/// - value: The cookie's string value.
12595
/// - path: The cookie's path.
12696
/// - domain: The domain of the cookie, defaults to nil.
127-
/// - expires: The cookie's expiration date, defaults to nil.
97+
/// - expires_timestamp: The cookie's expiration date, as a number of seconds since the Unix epoch. defaults to nil.
12898
/// - maxAge: The cookie's age in seconds, defaults to nil.
12999
/// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
130100
/// - secure: Whether this cookie should only be sent using secure channels, defaults to false.
131-
public init(name: String, value: String, path: String = "/", domain: String? = nil, expires: Date? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) {
101+
internal init(name: String, value: String, path: String = "/", domain: String? = nil, expires_timestamp: Int64? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) {
132102
self.name = name
133103
self.value = value
134104
self.path = path
135105
self.domain = domain
136-
self.expires = expires
106+
self.expires_timestamp = expires_timestamp
137107
self.maxAge = maxAge
138108
self.httpOnly = httpOnly
139109
self.secure = secure
140110
}
111+
}
112+
}
141113

142-
func parseComponent(_ component: String) -> (String?, String?) {
143-
let nameAndValue = component.split(separator: "=", maxSplits: 1).map {
144-
$0.trimmingCharacters(in: .whitespaces)
145-
}
146-
if nameAndValue.count == 2 {
147-
return (nameAndValue[0].lowercased(), nameAndValue[1])
148-
} else if nameAndValue.count == 1 {
149-
return (nameAndValue[0].lowercased(), nil)
150-
}
151-
return (nil, nil)
152-
}
114+
extension HTTPClient.Response {
115+
/// List of HTTP cookies returned by the server.
116+
public var cookies: [HTTPClient.Cookie] {
117+
return self.headers["set-cookie"].compactMap { HTTPClient.Cookie(header: $0, defaultDomain: self.host) }
153118
}
154119
}
155120

156-
extension String {
157-
fileprivate func omittingQuotes() -> String {
121+
extension StringProtocol {
122+
fileprivate func omittingQuotes() -> SubSequence {
158123
let dquote = "\""
159-
if !hasPrefix(dquote) || !hasSuffix(dquote) {
160-
return self
124+
guard hasPrefix(dquote), hasSuffix(dquote) else {
125+
return self[startIndex..<endIndex]
161126
}
127+
return self.dropFirst().dropLast()
128+
}
162129

163-
let begin = index(after: startIndex)
164-
let end = index(before: endIndex)
165-
return String(self[begin..<end])
130+
fileprivate func trimmingWhitespace() -> SubSequence {
131+
guard let start = self.firstIndex(where: { !$0.isWhitespace }) else {
132+
return self[self.endIndex..<self.endIndex]
133+
}
134+
let end = self.lastIndex(where: { !$0.isWhitespace })!
135+
return self[start...end]
136+
}
137+
138+
/// Splits this string at the first "=" sign, and trims whitespace from each side.
139+
fileprivate func parseKeyValuePair() -> (key: SubSequence, value: SubSequence)? {
140+
guard let keyValueSeparator = self.firstIndex(of: "=") else {
141+
return nil
142+
}
143+
let trimmedName = self[..<keyValueSeparator].trimmingWhitespace()
144+
let trimmedValue = self[self.index(after: keyValueSeparator)...].trimmingWhitespace()
145+
return (trimmedName, trimmedValue)
146+
}
147+
148+
/// If this string is a key-value pair, splits it and normalizes the key.
149+
/// Otherwise, return this string, trimmed of whitespace and normalized to lowercase.
150+
fileprivate func parseCookieComponent() -> (String?, String?) {
151+
guard let (trimmedName, trimmedValue) = self.parseKeyValuePair() else {
152+
let trimmedName = self.trimmingWhitespace()
153+
return (trimmedName.isEmpty ? nil : trimmedName.lowercased(), nil)
154+
}
155+
guard !trimmedName.isEmpty else {
156+
return (nil, nil)
157+
}
158+
return (trimmedName.lowercased(), trimmedValue.isEmpty ? nil : String(trimmedValue))
166159
}
167160
}
168161

169-
extension HTTPClient.Response {
170-
/// List of HTTP cookies returned by the server.
171-
public var cookies: [HTTPClient.Cookie] {
172-
return self.headers["set-cookie"].compactMap { HTTPClient.Cookie(header: $0, defaultDomain: self.host) }
162+
#if canImport(Darwin)
163+
import Darwin
164+
#elseif canImport(Glibc)
165+
import Glibc
166+
#endif
167+
import CShims
168+
169+
private let posixLocale: locale_t = {
170+
// All POSIX systems must provide a "POSIX" locale, and its date/time formats are US English.
171+
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap07.html#tag_07_03_05
172+
newlocale(LC_TIME_MASK | LC_NUMERIC_MASK, "POSIX", nil)!
173+
}()
174+
175+
private func parseCookieTime(_ string: String) -> Int64? {
176+
var timeComps = tm()
177+
var hasExplicitTimeZone = false
178+
parse: do {
179+
if swiftahc_cshims_strptime_l(string, "%a, %d %b %Y %H:%M:%S %Z", &timeComps, .init(posixLocale)) != nil {
180+
hasExplicitTimeZone = true
181+
break parse
182+
}
183+
timeComps = tm()
184+
if swiftahc_cshims_strptime_l(string, "%a, %d-%b-%y %H:%M:%S %Z", &timeComps, .init(posixLocale)) != nil {
185+
hasExplicitTimeZone = true
186+
break parse
187+
}
188+
timeComps = tm()
189+
if swiftahc_cshims_strptime_l(string, "%a %b %d %H:%M:%S %Y", &timeComps, .init(posixLocale)) != nil {
190+
break parse
191+
}
192+
return nil
193+
}
194+
#if canImport(Darwin)
195+
// Darwin converts values with explicit time-zones to the local time-zone. Glibc appears not to.
196+
// https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/strptime.3.html
197+
if hasExplicitTimeZone {
198+
let timestamp = Int64(mktime(&timeComps))
199+
return timestamp == -1 && errno == EOVERFLOW ? nil : timestamp
173200
}
201+
#endif
202+
let timestamp = Int64(timegm(&timeComps))
203+
return timestamp == -1 && errno == EOVERFLOW ? nil : timestamp
174204
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module CShims {
2+
header "shims.h"
3+
export *
4+
}

Sources/CShims/include/shims.h

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2018-2019 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
#ifndef ASYNC_HTTP_CLIENT_CSHIMS_H
16+
#define ASYNC_HTTP_CLIENT_CSHIMS_H
17+
18+
#include <time.h>
19+
20+
char* _Nullable swiftahc_cshims_strptime(
21+
const char * _Nonnull input,
22+
const char * _Nonnull format,
23+
struct tm * _Nonnull result
24+
);
25+
26+
char* _Nullable swiftahc_cshims_strptime_l(
27+
const char * _Nonnull input,
28+
const char * _Nonnull format,
29+
struct tm * _Nonnull result,
30+
void * _Nullable locale
31+
);
32+
33+
#endif // ASYNC_HTTP_CLIENT_CSHIMS_H

Sources/CShims/shims.c

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2018-2019 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
#if __APPLE__
16+
#include <xlocale.h>
17+
#elif __linux__
18+
#define _GNU_SOURCE
19+
#include <locale.h>
20+
#endif
21+
22+
#include <time.h>
23+
24+
char* swiftahc_cshims_strptime(const char * string, const char * format, struct tm * result) {
25+
return strptime(string, format, result);
26+
}
27+
28+
char* swiftahc_cshims_strptime_l(const char * string, const char * format, struct tm * result, void * locale) {
29+
// The pointer cast is fine as long we make sure it really points to a locale_t.
30+
return strptime_l(string, format, result, (locale_t)locale);
31+
}

Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ extension HTTPClientCookieTests {
3232
("testMalformedCookies", testMalformedCookies),
3333
("testCookieExpiresDateParsing", testCookieExpiresDateParsing),
3434
("testQuotedCookies", testQuotedCookies),
35+
("testCookieExpiresDateParsingWithNonEnglishLocale", testCookieExpiresDateParsingWithNonEnglishLocale),
3536
]
3637
}
3738
}

0 commit comments

Comments
 (0)