1212//
1313//===----------------------------------------------------------------------===//
1414
15- import Foundation
1615import NIOHTTP1
16+ #if canImport(Darwin)
17+ import Darwin
18+ #elseif canImport(Glibc)
19+ import Glibc
20+ #endif
21+ import CAsyncHTTPClient
1722
1823extension 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,79 +47,72 @@ 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+ // 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 {
5054 return nil
5155 }
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 {
5857 return nil
5958 }
60-
61- self . name = nameAndValue [ 0 ]
62- self . value = nameAndValue [ 1 ] . omittingQuotes ( )
63-
64- guard !self . name. isEmpty else {
59+ guard !trimmedName. isEmpty else {
6560 return nil
6661 }
6762
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
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 {
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
8580 continue
8681 }
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 {
9585 continue
9686 }
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
10192 continue
10293 }
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
10798 }
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 " , _) ? :
111106 self . secure = true
112- case ( " httponly " , nil ) :
107+ case ( " httponly " , _ ) ? :
113108 self . httpOnly = true
114109 default :
115110 continue
116111 }
117112 }
113+
114+ self . domain = parsedDomain. map { Substring ( $0) . lowercased ( ) } ?? defaultDomain. lowercased ( )
115+ self . path = parsedPath. map { String ( aligningUTF8: $0) } ?? " / "
118116 }
119117
120118 /// Create HTTP cookie.
@@ -124,51 +122,114 @@ extension HTTPClient {
124122 /// - value: The cookie's string value.
125123 /// - path: The cookie's path.
126124 /// - 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.
128126 /// - maxAge: The cookie's age in seconds, defaults to nil.
129127 /// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
130128 /// - 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 ) {
132130 self . name = name
133131 self . value = value
134132 self . path = path
135133 self . domain = domain
136- self . expires = expires
134+ self . expires_timestamp = expires_timestamp
137135 self . maxAge = maxAge
138136 self . httpOnly = httpOnly
139137 self . secure = secure
140138 }
139+ }
140+ }
141141
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) }
153146 }
154147}
155148
156149extension 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
161181 }
182+ let trimmedName = self [ ..< keyValueSeparator] . trimmingASCIISpaces ( )
183+ let trimmedValue = self [ self . index ( after: keyValueSeparator) ... ] . trimmingASCIISpaces ( )
184+ return ( trimmedName, trimmedValue)
185+ }
162186
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)
166196 }
167197}
168198
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
173232 }
233+ let timestamp = Int64 ( timegm ( & timeComponents) )
234+ return timestamp == - 1 && errno == EOVERFLOW ? nil : timestamp
174235}
0 commit comments