12
12
//
13
13
//===----------------------------------------------------------------------===//
14
14
15
- import Foundation
16
15
import NIOHTTP1
16
+ #if canImport(Darwin)
17
+ import Darwin
18
+ #elseif canImport(Glibc)
19
+ import Glibc
20
+ #endif
21
+ import CAsyncHTTPClient
17
22
18
23
extension HTTPClient {
19
24
/// A representation of an HTTP cookie.
@@ -26,8 +31,8 @@ extension HTTPClient {
26
31
public var path : String
27
32
/// The domain of the cookie.
28
33
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 ?
31
36
/// The cookie's age in seconds.
32
37
public var maxAge : Int ?
33
38
/// Whether the cookie should only be sent to HTTP servers.
@@ -42,79 +47,72 @@ extension HTTPClient {
42
47
/// - defaultDomain: Default domain to use if cookie was sent without one.
43
48
/// - returns: nil if the header is invalid.
44
49
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
+ // The parsing of "Set-Cookie" headers is defined by Section 5.2, RFC-6265:
51
+ // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2
52
+ var components = header. utf8. split ( separator: UInt8 ( ascii: " ; " ) , omittingEmptySubsequences: false ) [ ... ]
53
+ guard let keyValuePair = components. popFirst ( ) ? . trimmingASCIISpaces ( ) else {
50
54
return nil
51
55
}
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 {
56
+ guard let ( trimmedName, trimmedValue) = keyValuePair. parseKeyValuePair ( ) else {
58
57
return nil
59
58
}
60
-
61
- self . name = nameAndValue [ 0 ]
62
- self . value = nameAndValue [ 1 ] . omittingQuotes ( )
63
-
64
- guard !self . name. isEmpty else {
59
+ guard !trimmedName. isEmpty else {
65
60
return nil
66
61
}
67
62
68
- self . path = " / "
69
- self . domain = defaultDomain
70
- self . expires = nil
63
+ self . name = String ( aligningUTF8 : trimmedName )
64
+ self . value = String ( aligningUTF8 : trimmedValue . trimmingPairedASCIIQuote ( ) )
65
+ self . expires_timestamp = nil
71
66
self . maxAge = nil
72
67
self . httpOnly = false
73
68
self . secure = false
74
69
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 {
70
+ var parsedPath : String . UTF8View . SubSequence ?
71
+ var parsedDomain : String . UTF8View . SubSequence ?
72
+
73
+ for component in components {
74
+ switch component . parseCookieComponent ( ) {
75
+ case ( " path " , let value) ? :
76
+ // Unlike other values, unspecified, empty, and invalid paths reset to the default path.
77
+ // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4
78
+ guard let value = value , value . first == UInt8 ( ascii : " / " ) else {
79
+ parsedPath = nil
85
80
continue
86
81
}
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
82
+ parsedPath = value
83
+ case ( " domain " , let value) ? :
84
+ guard var value = value, !value. isEmpty else {
95
85
continue
96
86
}
97
-
98
- formatter. dateFormat = " EEE, dd-MMM-yy HH:mm:ss z "
99
- if let date = formatter. date ( from: value) {
100
- self . expires = date
87
+ if value. first == UInt8 ( ascii: " . " ) {
88
+ value. removeFirst ( )
89
+ }
90
+ guard !value. isEmpty else {
91
+ parsedDomain = nil
101
92
continue
102
93
}
103
-
104
- formatter . dateFormat = " EEE MMM d hh:mm:s yyyy "
105
- if let date = formatter . date ( from : value) {
106
- self . expires = date
94
+ parsedDomain = value
95
+ case ( " expires " , let value ) ? :
96
+ guard let value = value , let timestamp = parseCookieTime ( value) else {
97
+ continue
107
98
}
108
- case ( " max-age " , let value) :
109
- self . maxAge = value. flatMap ( Int . init)
110
- case ( " secure " , nil ) :
99
+ self . expires_timestamp = timestamp
100
+ case ( " max-age " , let value) ? :
101
+ guard let value = value, let age = Int ( Substring ( value) ) else {
102
+ continue
103
+ }
104
+ self . maxAge = age
105
+ case ( " secure " , _) ? :
111
106
self . secure = true
112
- case ( " httponly " , nil ) :
107
+ case ( " httponly " , _ ) ? :
113
108
self . httpOnly = true
114
109
default :
115
110
continue
116
111
}
117
112
}
113
+
114
+ self . domain = parsedDomain. map { Substring ( $0) . lowercased ( ) } ?? defaultDomain. lowercased ( )
115
+ self . path = parsedPath. map { String ( aligningUTF8: $0) } ?? " / "
118
116
}
119
117
120
118
/// Create HTTP cookie.
@@ -124,51 +122,114 @@ extension HTTPClient {
124
122
/// - value: The cookie's string value.
125
123
/// - path: The cookie's path.
126
124
/// - domain: The domain of the cookie, defaults to nil.
127
- /// - expires : The cookie's expiration date, defaults to nil.
125
+ /// - expires_timestamp : The cookie's expiration date, as a number of seconds since the Unix epoch. defaults to nil.
128
126
/// - maxAge: The cookie's age in seconds, defaults to nil.
129
127
/// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
130
128
/// - 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 ) {
129
+ internal init ( name: String , value: String , path: String = " / " , domain: String ? = nil , expires_timestamp : Int64 ? = nil , maxAge: Int ? = nil , httpOnly: Bool = false , secure: Bool = false ) {
132
130
self . name = name
133
131
self . value = value
134
132
self . path = path
135
133
self . domain = domain
136
- self . expires = expires
134
+ self . expires_timestamp = expires_timestamp
137
135
self . maxAge = maxAge
138
136
self . httpOnly = httpOnly
139
137
self . secure = secure
140
138
}
139
+ }
140
+ }
141
141
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
- }
142
+ extension HTTPClient . Response {
143
+ /// List of HTTP cookies returned by the server.
144
+ public var cookies : [ HTTPClient . Cookie ] {
145
+ return self . headers [ " set-cookie " ] . compactMap { HTTPClient . Cookie ( header: $0, defaultDomain: self . host) }
153
146
}
154
147
}
155
148
156
149
extension String {
157
- fileprivate func omittingQuotes( ) -> String {
158
- let dquote = " \" "
159
- if !hasPrefix( dquote) || !hasSuffix( dquote) {
160
- return self
150
+ /// Creates a String from a slice of UTF8 code-units, aligning the bounds to unicode scalar boundaries if needed.
151
+ fileprivate init ( aligningUTF8 utf8Slice: String . UTF8View . SubSequence ) {
152
+ self . init ( Substring ( utf8Slice) )
153
+ }
154
+ }
155
+
156
+ extension String . UTF8View . SubSequence {
157
+ fileprivate func trimmingASCIISpaces( ) -> SubSequence {
158
+ guard let start = self . firstIndex ( where: { $0 != UInt8 ( ascii: " " ) } ) else {
159
+ return self [ self . endIndex..< self . endIndex]
160
+ }
161
+ let end = self . lastIndex ( where: { $0 != UInt8 ( ascii: " " ) } ) !
162
+ return self [ start... end]
163
+ }
164
+
165
+ /// If this collection begins and ends with an ASCII double-quote ("),
166
+ /// returns a version of self trimmed of those quotes. Otherwise, returns self.
167
+ fileprivate func trimmingPairedASCIIQuote( ) -> SubSequence {
168
+ let quoteChar = UInt8 ( ascii: " \" " )
169
+ var trimmed = self
170
+ if trimmed. popFirst ( ) == quoteChar && trimmed. popLast ( ) == quoteChar {
171
+ return trimmed
172
+ }
173
+ return self
174
+ }
175
+
176
+ /// Splits this collection in to a key and value at the first ASCII '=' character.
177
+ /// Both the key and value are trimmed of ASCII spaces.
178
+ fileprivate func parseKeyValuePair( ) -> ( key: SubSequence , value: SubSequence ) ? {
179
+ guard let keyValueSeparator = self . firstIndex ( of: UInt8 ( ascii: " = " ) ) else {
180
+ return nil
161
181
}
182
+ let trimmedName = self [ ..< keyValueSeparator] . trimmingASCIISpaces ( )
183
+ let trimmedValue = self [ self . index ( after: keyValueSeparator) ... ] . trimmingASCIISpaces ( )
184
+ return ( trimmedName, trimmedValue)
185
+ }
162
186
163
- let begin = index ( after: startIndex)
164
- let end = index ( before: endIndex)
165
- return String ( self [ begin..< end] )
187
+ /// Parses this collection as either a key-value pair, or a plain key.
188
+ /// The returned key is trimmed of ASCII spaces and normalized to lowercase.
189
+ /// The returned value is trimmed of ASCII spaces.
190
+ fileprivate func parseCookieComponent( ) -> ( key: String , value: SubSequence ? ) ? {
191
+ let ( trimmedName, trimmedValue) = self . parseKeyValuePair ( ) ?? ( self . trimmingASCIISpaces ( ) , nil )
192
+ guard !trimmedName. isEmpty else {
193
+ return nil
194
+ }
195
+ return ( Substring ( trimmedName) . lowercased ( ) , trimmedValue)
166
196
}
167
197
}
168
198
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) }
199
+ private let posixLocale : UnsafeMutableRawPointer = {
200
+ // All POSIX systems must provide a "POSIX" locale, and its date/time formats are US English.
201
+ // https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap07.html#tag_07_03_05
202
+ let _posixLocale = newlocale ( LC_TIME_MASK | LC_NUMERIC_MASK, " POSIX " , nil ) !
203
+ return UnsafeMutableRawPointer ( _posixLocale)
204
+ } ( )
205
+
206
+ private func parseTimestamp( _ utf8: String . UTF8View . SubSequence , format: String ) -> tm ? {
207
+ var timeComponents = tm ( )
208
+ let success = Substring ( utf8) . withCString { cString in
209
+ swiftahc_cshims_strptime_l ( cString, format, & timeComponents, posixLocale)
210
+ }
211
+ return success ? timeComponents : nil
212
+ }
213
+
214
+ private func parseCookieTime( _ timestampUTF8: String . UTF8View . SubSequence ) -> Int64 ? {
215
+ if timestampUTF8. contains ( where: { $0 < 0x20 /* Control characters */ || $0 == 0x7F /* DEL */ } ) {
216
+ return nil
217
+ }
218
+ var timestampUTF8 = timestampUTF8
219
+ if timestampUTF8. hasSuffix ( " GMT " . utf8) {
220
+ let timezoneStart = timestampUTF8. index ( timestampUTF8. endIndex, offsetBy: - 3 )
221
+ timestampUTF8 = timestampUTF8 [ ..< timezoneStart] . trimmingASCIISpaces ( )
222
+ guard timestampUTF8. endIndex != timezoneStart else {
223
+ return nil
224
+ }
225
+ }
226
+ guard
227
+ var timeComponents = parseTimestamp ( timestampUTF8, format: " %a, %d %b %Y %H:%M:%S " )
228
+ ?? parseTimestamp ( timestampUTF8, format: " %a, %d-%b-%y %H:%M:%S " )
229
+ ?? parseTimestamp ( timestampUTF8, format: " %a %b %d %H:%M:%S %Y " )
230
+ else {
231
+ return nil
173
232
}
233
+ let timestamp = Int64 ( timegm ( & timeComponents) )
234
+ return timestamp == - 1 && errno == EOVERFLOW ? nil : timestamp
174
235
}
0 commit comments