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,74 +47,40 @@ 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
+ var components = header. utf8. split ( separator: UInt8 ( ascii: " ; " ) , omittingEmptySubsequences: false ) [ ... ]
51
+ guard let keyValuePair = components. popFirst ( ) ? . trimmingASCIISpaces ( ) else {
50
52
return nil
51
53
}
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 {
58
55
return nil
59
56
}
60
-
61
- self . name = nameAndValue [ 0 ]
62
- self . value = nameAndValue [ 1 ] . omittingQuotes ( )
63
-
64
- guard !self . name. isEmpty else {
57
+ guard !trimmedName. isEmpty else {
65
58
return nil
66
59
}
67
-
60
+ // FIXME: The parsed values should be validated to ensure they only contain ASCII characters allowed by RFC-6265.
61
+ // https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1
62
+ self . name = String ( utf8Slice: trimmedName, in: header)
63
+ self . value = String ( utf8Slice: trimmedValue. trimmingPairedASCIIQuote ( ) , in: header)
68
64
self . path = " / "
69
65
self . domain = defaultDomain
70
- self . expires = nil
66
+ self . expires_timestamp = nil
71
67
self . maxAge = nil
72
68
self . httpOnly = false
73
69
self . secure = false
74
70
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 ) :
71
+ for component in components {
72
+ switch component. parseCookieComponent ( ) {
73
+ case ( " path " , . some( let value) ) ? :
74
+ self . path = String ( utf8Slice: value, in: header)
75
+ case ( " domain " , . some( let value) ) ? :
76
+ self . domain = String ( utf8Slice: value, in: header)
77
+ case ( " expires " , . some( let value) ) ? :
78
+ self . expires_timestamp = parseCookieTime ( value, in: header)
79
+ case ( " max-age " , . some( let value) ) ? :
80
+ self . maxAge = Int ( Substring ( value) )
81
+ case ( " secure " , nil ) ? :
111
82
self . secure = true
112
- case ( " httponly " , nil ) :
83
+ case ( " httponly " , nil ) ? :
113
84
self . httpOnly = true
114
85
default :
115
86
continue
@@ -124,51 +95,117 @@ extension HTTPClient {
124
95
/// - value: The cookie's string value.
125
96
/// - path: The cookie's path.
126
97
/// - domain: The domain of the cookie, defaults to nil.
127
- /// - expires : The cookie's expiration date, defaults to nil.
98
+ /// - expires_timestamp : The cookie's expiration date, as a number of seconds since the Unix epoch. defaults to nil.
128
99
/// - maxAge: The cookie's age in seconds, defaults to nil.
129
100
/// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
130
101
/// - 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 ) {
102
+ 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
103
self . name = name
133
104
self . value = value
134
105
self . path = path
135
106
self . domain = domain
136
- self . expires = expires
107
+ self . expires_timestamp = expires_timestamp
137
108
self . maxAge = maxAge
138
109
self . httpOnly = httpOnly
139
110
self . secure = secure
140
111
}
112
+ }
113
+ }
141
114
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
- }
115
+ extension HTTPClient . Response {
116
+ /// List of HTTP cookies returned by the server.
117
+ public var cookies : [ HTTPClient . Cookie ] {
118
+ return self . headers [ " set-cookie " ] . compactMap { HTTPClient . Cookie ( header: $0, defaultDomain: self . host) }
153
119
}
154
120
}
155
121
156
122
extension String {
157
- fileprivate func omittingQuotes( ) -> String {
158
- let dquote = " \" "
159
- if !hasPrefix( dquote) || !hasSuffix( dquote) {
160
- return self
123
+ /// Creates a String from a slice of UTF8 code-units, aligning the bounds to unicode scalar boundaries if needed.
124
+ fileprivate init ( utf8Slice: String . UTF8View . SubSequence , in base: String ) {
125
+ self . init ( base [ utf8Slice. startIndex..< utf8Slice. endIndex] )
126
+ }
127
+ }
128
+
129
+ extension String . UTF8View . SubSequence {
130
+ fileprivate func trimmingASCIISpaces( ) -> SubSequence {
131
+ guard let start = self . firstIndex ( where: { $0 != UInt8 ( ascii: " " ) } ) else {
132
+ return self [ self . endIndex..< self . endIndex]
161
133
}
134
+ let end = self . lastIndex ( where: { $0 != UInt8 ( ascii: " " ) } ) !
135
+ return self [ start... end]
136
+ }
162
137
163
- let begin = index ( after: startIndex)
164
- let end = index ( before: endIndex)
165
- return String ( self [ begin..< end] )
138
+ /// If this collection begins and ends with an ASCII double-quote ("),
139
+ /// returns a version of self trimmed of those quotes. Otherwise, returns self.
140
+ fileprivate func trimmingPairedASCIIQuote( ) -> SubSequence {
141
+ let quoteChar = UInt8 ( ascii: " \" " )
142
+ var trimmed = self
143
+ if trimmed. popFirst ( ) == quoteChar && trimmed. popLast ( ) == quoteChar {
144
+ return trimmed
145
+ }
146
+ return self
147
+ }
148
+
149
+ /// Splits this collection in to a key and value at the first ASCII '=' character.
150
+ /// Both the key and value are trimmed of ASCII spaces.
151
+ fileprivate func parseKeyValuePair( ) -> ( key: SubSequence , value: SubSequence ) ? {
152
+ guard let keyValueSeparator = self . firstIndex ( of: UInt8 ( ascii: " = " ) ) else {
153
+ return nil
154
+ }
155
+ let trimmedName = self [ ..< keyValueSeparator] . trimmingASCIISpaces ( )
156
+ let trimmedValue = self [ self . index ( after: keyValueSeparator) ... ] . trimmingASCIISpaces ( )
157
+ return ( trimmedName, trimmedValue)
158
+ }
159
+
160
+ /// Parses this collection as either a key-value pair, or a plain key.
161
+ /// The returned key is trimmed of ASCII spaces and normalized to lowercase.
162
+ /// The returned value is trimmed of ASCII spaces.
163
+ fileprivate func parseCookieComponent( ) -> ( key: String , value: SubSequence ? ) ? {
164
+ let ( trimmedName, trimmedValue) = self . parseKeyValuePair ( ) ?? ( self . trimmingASCIISpaces ( ) , nil )
165
+ guard !trimmedName. isEmpty else {
166
+ return nil
167
+ }
168
+ return ( Substring ( trimmedName) . lowercased ( ) , trimmedValue)
166
169
}
167
170
}
168
171
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) }
172
+ private let posixLocale : UnsafeMutableRawPointer = {
173
+ // All POSIX systems must provide a "POSIX" locale, and its date/time formats are US English.
174
+ // https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap07.html#tag_07_03_05
175
+ let _posixLocale = newlocale ( LC_TIME_MASK | LC_NUMERIC_MASK, " POSIX " , nil ) !
176
+ return UnsafeMutableRawPointer ( _posixLocale)
177
+ } ( )
178
+
179
+ private func parseTimestamp( _ string: String , format: String ) -> tm ? {
180
+ var timeComponents = tm ( )
181
+ guard swiftahc_cshims_strptime_l ( string, format, & timeComponents, posixLocale) else {
182
+ return nil
183
+ }
184
+ return timeComponents
185
+ }
186
+
187
+ private func parseCookieTime( _ timestampUTF8: String . UTF8View . SubSequence , in header: String ) -> Int64 ? {
188
+ if timestampUTF8. contains ( where: { $0 < 0x20 /* Control characters */ || $0 == 0x7F /* DEL */ } ) {
189
+ return nil
190
+ }
191
+ let timestampString : String
192
+ if timestampUTF8. hasSuffix ( " GMT " . utf8) {
193
+ let timezoneStart = timestampUTF8. index ( timestampUTF8. endIndex, offsetBy: - 3 )
194
+ let trimmedTimestampUTF8 = timestampUTF8 [ ..< timezoneStart] . trimmingASCIISpaces ( )
195
+ guard trimmedTimestampUTF8. endIndex != timezoneStart else {
196
+ return nil
197
+ }
198
+ timestampString = String ( utf8Slice: trimmedTimestampUTF8, in: header)
199
+ } else {
200
+ timestampString = String ( utf8Slice: timestampUTF8, in: header)
201
+ }
202
+ guard
203
+ var timeComponents = parseTimestamp ( timestampString, format: " %a, %d %b %Y %H:%M:%S " )
204
+ ?? parseTimestamp ( timestampString, format: " %a, %d-%b-%y %H:%M:%S " )
205
+ ?? parseTimestamp ( timestampString, format: " %a %b %d %H:%M:%S %Y " )
206
+ else {
207
+ return nil
173
208
}
209
+ let timestamp = Int64 ( timegm ( & timeComponents) )
210
+ return timestamp == - 1 && errno == EOVERFLOW ? nil : timestamp
174
211
}
0 commit comments