Skip to content

Commit 68c51ea

Browse files
committed
Redo HTTP cookie parsing using strptime
1 parent d372bdc commit 68c51ea

File tree

8 files changed

+418
-100
lines changed

8 files changed

+418
-100
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: "CAsyncHTTPClient"),
3233
.target(
3334
name: "AsyncHTTPClient",
3435
dependencies: [
36+
.target(name: "CAsyncHTTPClient"),
3537
.product(name: "NIO", package: "swift-nio"),
3638
.product(name: "NIOCore", package: "swift-nio"),
3739
.product(name: "NIOPosix", package: "swift-nio"),
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2018-2021 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+
get {
24+
expires_timestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
25+
}
26+
set {
27+
expires_timestamp = newValue.map { Int64($0.timeIntervalSince1970) }
28+
}
29+
}
30+
31+
/// Create HTTP cookie.
32+
///
33+
/// - parameters:
34+
/// - name: The name of the cookie.
35+
/// - value: The cookie's string value.
36+
/// - path: The cookie's path.
37+
/// - domain: The domain of the cookie, defaults to nil.
38+
/// - expires: The cookie's expiration date, defaults to nil.
39+
/// - maxAge: The cookie's age in seconds, defaults to nil.
40+
/// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
41+
/// - secure: Whether this cookie should only be sent using secure channels, defaults to false.
42+
public init(name: String, value: String, path: String = "/", domain: String? = nil, expires: Date? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) {
43+
self.init(
44+
name: name,
45+
value: value,
46+
path: path,
47+
domain: domain,
48+
expires_timestamp: expires.map { Int64($0.timeIntervalSince1970) },
49+
maxAge: maxAge,
50+
httpOnly: httpOnly,
51+
secure: secure
52+
)
53+
}
54+
}

Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift

Lines changed: 117 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15-
import Foundation
1615
import NIOHTTP1
16+
#if canImport(Darwin)
17+
import Darwin
18+
#elseif canImport(Glibc)
19+
import Glibc
20+
#endif
21+
import CAsyncHTTPClient
1722

1823
extension HTTPClient {
1924
/// A representation of an HTTP cookie.
@@ -26,8 +31,8 @@ extension HTTPClient {
2631
public var path: String
2732
/// The domain of the cookie.
2833
public var domain: String?
29-
/// The cookie's expiration date.
30-
public var expires: Date?
34+
/// The cookie's expiration date, as a number of seconds since the Unix epoch.
35+
var expires_timestamp: Int64?
3136
/// The cookie's age in seconds.
3237
public var maxAge: Int?
3338
/// Whether the cookie should only be sent to HTTP servers.
@@ -42,74 +47,39 @@ extension HTTPClient {
4247
/// - defaultDomain: Default domain to use if cookie was sent without one.
4348
/// - returns: nil if the header is invalid.
4449
public init?(header: String, defaultDomain: String) {
45-
let components = header.components(separatedBy: ";").map {
46-
$0.trimmingCharacters(in: .whitespaces)
47-
}
48-
49-
if components.isEmpty {
50+
var components = header.utf8.split(separator: UInt8(ascii: ";"), omittingEmptySubsequences: false)[...]
51+
guard let keyValuePair = components.popFirst()?.trimmingASCIISpaces() else {
5052
return nil
5153
}
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 {
54+
guard let (trimmedName, trimmedValue) = keyValuePair.parseKeyValuePair() else {
5855
return nil
5956
}
60-
61-
self.name = nameAndValue[0]
62-
self.value = nameAndValue[1].omittingQuotes()
63-
64-
guard !self.name.isEmpty else {
57+
guard !trimmedName.isEmpty else {
6558
return nil
6659
}
6760

61+
self.name = String(utf8Slice: trimmedName, in: header)
62+
self.value = String(utf8Slice: trimmedValue.trimmingPairedASCIIQuote(), in: header)
6863
self.path = "/"
6964
self.domain = defaultDomain
70-
self.expires = nil
65+
self.expires_timestamp = nil
7166
self.maxAge = nil
7267
self.httpOnly = false
7368
self.secure = false
7469

75-
for component in components[1...] {
76-
switch self.parseComponent(component) {
77-
case (nil, nil):
78-
continue
79-
case ("path", .some(let value)):
80-
self.path = value
81-
case ("domain", .some(let value)):
82-
self.domain = value
83-
case ("expires", let value):
84-
guard let value = value else {
85-
continue
86-
}
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-
}
108-
case ("max-age", let value):
109-
self.maxAge = value.flatMap(Int.init)
110-
case ("secure", nil):
70+
for component in components {
71+
switch component.parseCookieComponent() {
72+
case ("path", .some(let value))?:
73+
self.path = String(utf8Slice: value, in: header)
74+
case ("domain", .some(let value))?:
75+
self.domain = String(utf8Slice: value, in: header)
76+
case ("expires", .some(let value))?:
77+
self.expires_timestamp = parseCookieTime(value, in: header)
78+
case ("max-age", .some(let value))?:
79+
self.maxAge = Int(String(utf8Slice: value, in: header))
80+
case ("secure", nil)?:
11181
self.secure = true
112-
case ("httponly", nil):
82+
case ("httponly", nil)?:
11383
self.httpOnly = true
11484
default:
11585
continue
@@ -124,51 +94,116 @@ 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

156121
extension String {
157-
fileprivate func omittingQuotes() -> String {
158-
let dquote = "\""
159-
if !hasPrefix(dquote) || !hasSuffix(dquote) {
160-
return self
122+
fileprivate init(utf8Slice: String.UTF8View.SubSequence, in base: String) {
123+
self.init(base[utf8Slice.startIndex..<utf8Slice.endIndex])
124+
}
125+
}
126+
127+
extension String.UTF8View.SubSequence {
128+
fileprivate func trimmingASCIISpaces() -> SubSequence {
129+
guard let start = self.firstIndex(where: { $0 != UInt8(ascii: " ") }) else {
130+
return self[self.endIndex..<self.endIndex]
161131
}
132+
let end = self.lastIndex(where: { $0 != UInt8(ascii: " ") })!
133+
return self[start...end]
134+
}
162135

163-
let begin = index(after: startIndex)
164-
let end = index(before: endIndex)
165-
return String(self[begin..<end])
136+
/// If this collection begins and ends with an ASCII double-quote ("),
137+
/// returns a version of self trimmed of those quotes. Otherwise, returns self.
138+
fileprivate func trimmingPairedASCIIQuote() -> SubSequence {
139+
let quoteChar = UInt8(ascii: "\"")
140+
var trimmed = self
141+
if trimmed.popFirst() == quoteChar && trimmed.popLast() == quoteChar {
142+
return trimmed
143+
}
144+
return self
145+
}
146+
147+
/// Splits this collection in to a key and value at the first ASCII '=' character.
148+
/// Both the key and value are trimmed of ASCII spaces.
149+
fileprivate func parseKeyValuePair() -> (key: SubSequence, value: SubSequence)? {
150+
guard let keyValueSeparator = self.firstIndex(of: UInt8(ascii: "=")) else {
151+
return nil
152+
}
153+
let trimmedName = self[..<keyValueSeparator].trimmingASCIISpaces()
154+
let trimmedValue = self[self.index(after: keyValueSeparator)...].trimmingASCIISpaces()
155+
return (trimmedName, trimmedValue)
156+
}
157+
158+
/// Parses this collection as either a key-value pair, or a plain key.
159+
/// The returned key is trimmed of ASCII spaces and normalized to lowercase.
160+
/// The returned value is trimmed of ASCII spaces.
161+
fileprivate func parseCookieComponent() -> (key: String, value: SubSequence?)? {
162+
let (trimmedName, trimmedValue) = self.parseKeyValuePair() ?? (self.trimmingASCIISpaces(), nil)
163+
guard !trimmedName.isEmpty else {
164+
return nil
165+
}
166+
return (String(decoding: trimmedName, as: UTF8.self).lowercased(), trimmedValue)
166167
}
167168
}
168169

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) }
170+
private let posixLocale: UnsafeMutableRawPointer = {
171+
// All POSIX systems must provide a "POSIX" locale, and its date/time formats are US English.
172+
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap07.html#tag_07_03_05
173+
let _posixLocale = newlocale(LC_TIME_MASK | LC_NUMERIC_MASK, "POSIX", nil)!
174+
return UnsafeMutableRawPointer(_posixLocale)
175+
}()
176+
177+
private func parseTimestamp(_ string: String, format: String) -> tm? {
178+
var timeComponents = tm()
179+
guard swiftahc_cshims_strptime_l(string, format, &timeComponents, posixLocale) else {
180+
return nil
181+
}
182+
return timeComponents
183+
}
184+
185+
private func parseCookieTime(_ timestampUTF8: String.UTF8View.SubSequence, in header: String) -> Int64? {
186+
if timestampUTF8.contains(where: { $0 < 0x20 /* Control characters */ || $0 == 0x7F /* DEL */ }) {
187+
return nil
188+
}
189+
let timestampString: String
190+
if timestampUTF8.reversed().starts(with: "TMG".utf8) {
191+
let timezoneStart = timestampUTF8.index(timestampUTF8.endIndex, offsetBy: -3)
192+
let trimmedTimestampUTF8 = timestampUTF8[..<timezoneStart].trimmingASCIISpaces()
193+
guard trimmedTimestampUTF8.endIndex != timezoneStart else {
194+
return nil
195+
}
196+
timestampString = String(utf8Slice: trimmedTimestampUTF8, in: header)
197+
} else {
198+
timestampString = String(utf8Slice: timestampUTF8, in: header)
199+
}
200+
guard
201+
var timeComponents = parseTimestamp(timestampString, format: "%a, %d %b %Y %H:%M:%S")
202+
?? parseTimestamp(timestampString, format: "%a, %d-%b-%y %H:%M:%S")
203+
?? parseTimestamp(timestampString, format: "%a %b %d %H:%M:%S %Y")
204+
else {
205+
return nil
173206
}
207+
let timestamp = Int64(timegm(&timeComponents))
208+
return timestamp == -1 && errno == EOVERFLOW ? nil : timestamp
174209
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2018-2021 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 <stdbool.h>
23+
#include <time.h>
24+
25+
bool swiftahc_cshims_strptime(const char * string, const char * format, struct tm * result) {
26+
const char * firstNonProcessed = strptime(string, format, result);
27+
if (firstNonProcessed) {
28+
return *firstNonProcessed == 0;
29+
}
30+
return false;
31+
}
32+
33+
bool swiftahc_cshims_strptime_l(const char * string, const char * format, struct tm * result, void * locale) {
34+
// The pointer cast is fine as long we make sure it really points to a locale_t.
35+
const char * firstNonProcessed = strptime_l(string, format, result, (locale_t)locale);
36+
if (firstNonProcessed) {
37+
return *firstNonProcessed == 0;
38+
}
39+
return false;
40+
}

0 commit comments

Comments
 (0)