11import Foundation
22
33public class SessionCookieStore {
4-
4+
55 // MARK: - Private properties
66 private static let bundleIdentifier = Bundle . main. bundleIdentifier ?? " com.mendix.app "
77 private static let storageKey = bundleIdentifier + " sessionCookies "
88 private static let queue = DispatchQueue ( label: bundleIdentifier + " .session-cookie-store " , qos: . utility)
9-
9+ private static let chunkSize : Int = 64 * 1024
10+ private static let maxChunks : Int = 1000
11+
1012 // MARK: - Public API
1113 public static func restore( ) {
12-
14+
1315 guard let cookies = get ( key: storageKey) else {
1416 NSLog ( " SessionCookieStore: No cookies to restore " )
1517 return
1618 }
17-
19+
1820 let storage = HTTPCookieStorage . shared
1921 let existing = Set ( storage. cookies ?? [ ] )
2022 cookies. filter { !existing. contains ( $0) } . forEach { storage. setCookie ( $0) }
21-
22- clear ( ) // Clear stored cookies after restoration to avoid any side effects
23+
24+ clear ( )
2325 }
24-
26+
2527 public static func persist( ) {
2628 queue. async {
2729 let sessionCookies = HTTPCookieStorage . shared. cookies? . filter { isSessionCookie ( $0) } ?? [ ]
@@ -33,53 +35,184 @@ public class SessionCookieStore {
3335 set ( key: storageKey, cookies: sessionCookies)
3436 }
3537 }
36-
38+
39+ #if DEBUG
40+ /// Persist variant that calls `completion` once the keychain write finishes.
41+ /// Only compiled in DEBUG builds; used by the harness test bridge to await completion.
42+ public static func persist( completion: @escaping ( ) -> Void ) {
43+ queue. async {
44+ let sessionCookies = HTTPCookieStorage . shared. cookies? . filter { isSessionCookie ( $0) } ?? [ ]
45+ guard !sessionCookies. isEmpty else {
46+ clear ( )
47+ NSLog ( " SessionCookieStore: Clear existing session cookies from storage " )
48+ completion ( )
49+ return
50+ }
51+ set ( key: storageKey, cookies: sessionCookies)
52+ completion ( )
53+ }
54+ }
55+ #endif
56+
3757 public static func clear( ) {
58+ clearAllChunks ( key: storageKey)
3859 clear ( key: storageKey)
3960 }
40-
61+
4162 // MARK: - Private API
4263 private static func isSessionCookie( _ cookie: HTTPCookie ) -> Bool {
4364 return cookie. expiresDate == nil
4465 }
45-
66+
4667 private static func set( key: String , cookies: [ HTTPCookie ] ) {
4768 do {
4869 let data = try NSKeyedArchiver . archivedData ( withRootObject: cookies, requiringSecureCoding: false )
49- let storeQuery = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data] as CFDictionary
50- SecItemDelete ( storeQuery)
51- let status = SecItemAdd ( storeQuery, nil )
52- if status != noErr {
53- NSLog ( " SessionCookieStore: Failed to persist session cookies with status: \( status) " )
70+
71+ clear ( key: key)
72+ clearAllChunks ( key: key)
73+
74+ if data. count <= chunkSize {
75+ let storeQuery = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data] as CFDictionary
76+ let status = SecItemAdd ( storeQuery, nil )
77+ if status != noErr {
78+ NSLog ( " SessionCookieStore: Failed to persist session cookies with status: \( status) " )
79+ }
80+ } else {
81+ let chunkCount = min ( ( data. count + chunkSize - 1 ) / chunkSize, maxChunks)
82+
83+ var writtenChunkKeys : [ String ] = [ ]
84+ var failed = false
85+
86+ for i in 0 ..< chunkCount {
87+ let start = i * chunkSize
88+ let end = min ( start + chunkSize, data. count)
89+ let chunkKey = key + " _chunk_ \( i) "
90+ let chunkQuery = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: chunkKey, kSecValueData: Data ( data [ start..< end] ) ] as CFDictionary
91+ let status = SecItemAdd ( chunkQuery, nil )
92+ if status != noErr {
93+ NSLog ( " SessionCookieStore: Failed to write chunk \( i) with status: \( status) " )
94+ failed = true
95+ break
96+ }
97+ writtenChunkKeys. append ( chunkKey)
98+ }
99+
100+ guard !failed else {
101+ for chunkKey in writtenChunkKeys {
102+ SecItemDelete ( [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: chunkKey] as CFDictionary )
103+ }
104+ return
105+ }
106+
107+ // Commit marker written last — its presence guarantees all chunks are present
108+ let countKey = key + " _chunkcount "
109+ let countData = " \( chunkCount) " . data ( using: . utf8) !
110+ let countQuery = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: countKey, kSecValueData: countData] as CFDictionary
111+ let countStatus = SecItemAdd ( countQuery, nil )
112+ if countStatus != noErr {
113+ // Rollback
114+ for chunkKey in writtenChunkKeys {
115+ SecItemDelete ( [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: chunkKey] as CFDictionary )
116+ }
117+ NSLog ( " SessionCookieStore: Failed to write chunk count marker (status: \( countStatus) ), rolled back " )
118+ }
54119 }
55120 } catch {
56121 NSLog ( " SessionCookieStore: Failed to persist session cookies: \( error. localizedDescription) " )
57122 }
58123 }
59-
124+
60125 private static func get( key: String ) -> [ HTTPCookie ] ? {
61- do {
62- let query : [ CFString : Any ] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: true ]
63- var item : CFTypeRef ?
64- let status = SecItemCopyMatching ( query as CFDictionary , & item)
65- if status == errSecSuccess, let data = item as? Data {
126+ let countKey = key + " _chunkcount "
127+ let countQuery : [ CFString : Any ] = [
128+ kSecClass: kSecClassGenericPassword,
129+ kSecAttrAccount: countKey,
130+ kSecReturnData: true ,
131+ kSecUseAuthenticationUI: kSecUseAuthenticationUIFail
132+ ]
133+ var countRef : CFTypeRef ?
134+ let countStatus = SecItemCopyMatching ( countQuery as CFDictionary , & countRef)
135+
136+ if countStatus == errSecSuccess,
137+ let countData = countRef as? Data ,
138+ let countStr = String ( data: countData, encoding: . utf8) ,
139+ let chunkCount = Int ( countStr) {
140+
141+ var assembled = Data ( )
142+ for i in 0 ..< chunkCount {
143+ let chunkKey = key + " _chunk_ \( i) "
144+ let chunkQuery : [ CFString : Any ] = [
145+ kSecClass: kSecClassGenericPassword,
146+ kSecAttrAccount: chunkKey,
147+ kSecReturnData: true ,
148+ kSecUseAuthenticationUI: kSecUseAuthenticationUIFail
149+ ]
150+ var chunkRef : CFTypeRef ?
151+ let chunkStatus = SecItemCopyMatching ( chunkQuery as CFDictionary , & chunkRef)
152+ guard chunkStatus == errSecSuccess, let chunkData = chunkRef as? Data else {
153+ NSLog ( " SessionCookieStore: Failed to read chunk \( i) (status: \( chunkStatus) ), discarding chunked set " )
154+ clearAllChunks ( key: key)
155+ return nil
156+ }
157+ assembled. append ( chunkData)
158+ }
159+ do {
160+ let cookies = try NSKeyedUnarchiver . unarchivedObject ( ofClasses: [ NSArray . self, HTTPCookie . self] , from: assembled) as? [ HTTPCookie ]
161+ return cookies
162+ } catch {
163+ NSLog ( " SessionCookieStore: Failed to deserialize chunked cookies, clearing: \( error. localizedDescription) " )
164+ clearAllChunks ( key: key)
165+ return nil
166+ }
167+ }
168+
169+ // --- Legacy single-item format (backward compat on read) ---
170+ let query : [ CFString : Any ] = [
171+ kSecClass: kSecClassGenericPassword,
172+ kSecAttrAccount: key,
173+ kSecReturnData: true ,
174+ kSecUseAuthenticationUI: kSecUseAuthenticationUIFail
175+ ]
176+ var item : CFTypeRef ?
177+ let status = SecItemCopyMatching ( query as CFDictionary , & item)
178+
179+ if status == errSecInteractionNotAllowed {
180+ // Oversized/blocked item — remove so it never prompts again
181+ NSLog ( " SessionCookieStore: Blocked legacy item detected, clearing " )
182+ clear ( key: key)
183+ return nil
184+ } else if status == errSecSuccess, let data = item as? Data {
185+ do {
66186 let cookies = try NSKeyedUnarchiver . unarchivedObject ( ofClasses: [ NSArray . self, HTTPCookie . self] , from: data) as? [ HTTPCookie ]
67187 return cookies
68- } else {
69- NSLog ( " SessionCookieStore: No session cookies found with status: \( status) " )
188+ } catch {
189+ NSLog ( " SessionCookieStore: Failed to deserialize legacy cookies, clearing: \( error. localizedDescription) " )
190+ clear ( key: key)
70191 return nil
71192 }
72- } catch {
73- NSLog ( " SessionCookieStore: Failed to retrieve session cookies: \( error. localizedDescription) " )
193+ } else if status != errSecItemNotFound {
194+ NSLog ( " SessionCookieStore: Unreadable legacy item (status: \( status) ), clearing " )
195+ clear ( key: key)
196+ return nil
197+ } else {
198+ NSLog ( " SessionCookieStore: No session cookies found " )
74199 return nil
75200 }
76201 }
77-
202+
78203 private static func clear( key: String ) {
79204 let query = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: true ] as CFDictionary
80205 let status = SecItemDelete ( query)
81- if status != errSecSuccess {
206+ if status != errSecSuccess && status != errSecItemNotFound {
82207 NSLog ( " SessionCookieStore: Failed to clear cookies with status: \( status) " )
83208 }
84209 }
210+
211+ private static func clearAllChunks( key: String ) {
212+ SecItemDelete ( [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key + " _chunkcount " ] as CFDictionary )
213+ for i in 0 ..< maxChunks {
214+ let st = SecItemDelete ( [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key + " _chunk_ \( i) " ] as CFDictionary )
215+ if st == errSecItemNotFound { break }
216+ }
217+ }
85218}
0 commit comments