@onekeyfe/react-native-sni-connect 3.0.81 → 3.0.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,18 +28,6 @@ class SniConnectValidationTest {
28
28
  assertEquals("/v1?q=1", SniConnectValidation.normalizePath("v1?q=1"))
29
29
  }
30
30
 
31
- @Test
32
- fun canonicalizesEquivalentPublicIpLiterals() {
33
- assertEquals(
34
- "93.184.216.34",
35
- SniConnectValidation.canonicalizePublicIp("093.184.216.034"),
36
- )
37
- assertEquals(
38
- SniConnectValidation.canonicalizePublicIp("2001:4860:4860::8888"),
39
- SniConnectValidation.canonicalizePublicIp("2001:4860:4860:0:0:0:0:8888"),
40
- )
41
- }
42
-
43
31
  @Test
44
32
  fun rejectsIpLiteralHostnames() {
45
33
  assertValidationFails { SniConnectValidation.validateHostname("93.184.216.34") }
@@ -129,10 +117,8 @@ class SniConnectValidationTest {
129
117
 
130
118
  @Test
131
119
  fun enforcesRequestIdTimeoutAndBodyLimits() {
132
- SniConnectValidation.validateRequestId("界".repeat(42))
133
120
  assertValidationFails { SniConnectValidation.validateRequestId("") }
134
121
  assertValidationFails { SniConnectValidation.validateRequestId("x".repeat(129)) }
135
- assertValidationFails { SniConnectValidation.validateRequestId("界".repeat(43)) }
136
122
  assertValidationFails { SniConnectValidation.validateRequestId("req\n1") }
137
123
  assertValidationFails { SniConnectValidation.validateTimeout(0) }
138
124
  assertValidationFails { SniConnectValidation.validateTimeout(120_001) }
@@ -198,6 +184,32 @@ class SniConnectValidationTest {
198
184
  SniConnectValidation.validateMethodBody("OPTIONS", null)
199
185
  }
200
186
 
187
+ @Test
188
+ fun requestLimiterEnforcesGlobalAndPerDestinationLimits() {
189
+ val limiter = SniConnectRequestLimiter(
190
+ maxActiveRequests = 2,
191
+ maxActiveRequestsPerPair = 1,
192
+ )
193
+
194
+ val firstToken = limiter.acquire("Example.com", "93.184.216.34")
195
+ assertValidationFails {
196
+ limiter.acquire("example.com", "93.184.216.34")
197
+ }
198
+
199
+ val secondToken = limiter.acquire("example.com", "93.184.216.35")
200
+ assertValidationFails {
201
+ limiter.acquire("example.net", "93.184.216.36")
202
+ }
203
+
204
+ firstToken.release()
205
+ val replacementToken = limiter.acquire("example.com", "93.184.216.34")
206
+ firstToken.release()
207
+ secondToken.release()
208
+ replacementToken.release()
209
+
210
+ assertTrue(true)
211
+ }
212
+
201
213
  @Test
202
214
  fun classifiesSecurityFailuresAsFailClosedErrorCodes() {
203
215
  assertEquals(
@@ -232,18 +244,6 @@ class SniConnectValidationTest {
232
244
  "SNI_REQUEST_FAILED",
233
245
  classifySniFailureCode(IOException("connection reset")),
234
246
  )
235
- assertEquals(
236
- "SNI_CANCELLED",
237
- classifySniResponseFailureCode(IOException("cancelled"), true),
238
- )
239
- assertEquals(
240
- "SNI_REQUEST_TIMEOUT",
241
- classifySniResponseFailureCode(SocketTimeoutException("timeout"), false),
242
- )
243
- assertEquals(
244
- "SNI_RESPONSE_FAILED",
245
- classifySniResponseFailureCode(IOException("bad body"), false),
246
- )
247
247
  }
248
248
 
249
249
  private fun assertValidationFails(block: () -> Unit) {
package/ios/SniConnect.mm CHANGED
@@ -18,9 +18,6 @@
18
18
  reject:(RCTPromiseRejectBlock)reject;
19
19
  - (void)clearDNSCache:(RCTPromiseResolveBlock)resolve
20
20
  reject:(RCTPromiseRejectBlock)reject;
21
- - (void)getDebugSnapshot:(NSDictionary *)target
22
- resolve:(RCTPromiseResolveBlock)resolve
23
- reject:(RCTPromiseRejectBlock)reject;
24
21
  - (void)isProxyActiveForUrl:(NSString *)url
25
22
  resolve:(RCTPromiseResolveBlock)resolve
26
23
  reject:(RCTPromiseRejectBlock)reject;
@@ -92,16 +89,6 @@ RCT_EXPORT_MODULE(SniConnect)
92
89
  [_implementation clearDNSCache:resolve reject:reject];
93
90
  }
94
91
 
95
- - (void)getDebugSnapshot:(JS::NativeSniConnect::SniConnectDebugTarget &)target
96
- resolve:(RCTPromiseResolveBlock)resolve
97
- reject:(RCTPromiseRejectBlock)reject {
98
- NSDictionary *targetDict = @{
99
- @"ip": target.ip() ?: @"",
100
- @"hostname": target.hostname() ?: @"",
101
- };
102
- [_implementation getDebugSnapshot:targetDict resolve:resolve reject:reject];
103
- }
104
-
105
92
  - (void)isProxyActiveForUrl:(NSString *)url
106
93
  resolve:(RCTPromiseResolveBlock)resolve
107
94
  reject:(RCTPromiseRejectBlock)reject {
@@ -137,12 +124,6 @@ RCT_EXPORT_METHOD(clearDNSCache:(RCTPromiseResolveBlock)resolver
137
124
  [_implementation clearDNSCache:resolver reject:rejecter];
138
125
  }
139
126
 
140
- RCT_EXPORT_METHOD(getDebugSnapshot:(NSDictionary *)target
141
- resolver:(RCTPromiseResolveBlock)resolver
142
- rejecter:(RCTPromiseRejectBlock)rejecter) {
143
- [_implementation getDebugSnapshot:target resolve:resolver reject:rejecter];
144
- }
145
-
146
127
  RCT_EXPORT_METHOD(isProxyActiveForUrl:(NSString *)url
147
128
  resolver:(RCTPromiseResolveBlock)resolver
148
129
  rejecter:(RCTPromiseRejectBlock)rejecter) {
@@ -133,35 +133,6 @@ final class SniConnectImpl: NSObject {
133
133
  resolve(["success": true])
134
134
  }
135
135
 
136
- @objc
137
- public func getDebugSnapshot(
138
- _ target: NSDictionary,
139
- resolve: @escaping RCTPromiseResolveBlock,
140
- reject: @escaping RCTPromiseRejectBlock
141
- ) {
142
- do {
143
- guard let ip = target["ip"] as? String, !ip.isEmpty else {
144
- throw SniConnectError.invalidConfig("Missing ip")
145
- }
146
- guard let hostname = target["hostname"] as? String, !hostname.isEmpty else {
147
- throw SniConnectError.invalidConfig("Missing hostname")
148
- }
149
- try SniConnectValidation.validatePublicIP(ip)
150
- try SniConnectValidation.validateHostname(hostname)
151
- let snapshot = client.debugSnapshot(hostname: hostname, ip: ip)
152
- resolve([
153
- "activeRequests": snapshot.activeRequests,
154
- "activeRequestsForPair": snapshot.activeRequestsForPair,
155
- "pendingRequests": snapshot.pendingRequests,
156
- "pendingRequestsForPair": snapshot.pendingRequestsForPair,
157
- "activeRequestIdsForPair": snapshot.activeRequestIdsForPair,
158
- "pendingRequestIdsForPair": snapshot.pendingRequestIdsForPair,
159
- ])
160
- } catch {
161
- reject("SNI_INVALID_CONFIG", "\(error)", error)
162
- }
163
- }
164
-
165
136
  @objc
166
137
  public func isProxyActiveForUrl(
167
138
  _ url: String,
@@ -4,13 +4,13 @@ import UIKit
4
4
  import EMASCurl
5
5
 
6
6
  @objc(SniConnectPinnedDNSResolverBase)
7
- private class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver {
7
+ class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver {
8
8
  @objc class func resolveDomain(_ domain: String) -> String? {
9
9
  PinnedDNSResolverFactory.resolve(domain: domain, resolverClass: self)
10
10
  }
11
11
  }
12
12
 
13
- private enum PinnedDNSResolverFactory {
13
+ enum PinnedDNSResolverFactory {
14
14
  private static let queue = DispatchQueue(label: "com.onekey.sni.connect.pinned-dns-resolvers")
15
15
  private static var nextClassID = 0
16
16
  private static let registry = SniConnectPinnedResolverRegistry()
@@ -54,7 +54,7 @@ private enum PinnedDNSResolverFactory {
54
54
  }
55
55
  }
56
56
 
57
- private final class SniConnectPinnedResolverLease {
57
+ final class SniConnectPinnedResolverLease {
58
58
  private let hostname: String
59
59
  private let ip: String
60
60
  private let queue = DispatchQueue(label: "com.onekey.sni.connect.resolver-lease")
@@ -90,15 +90,22 @@ private final class SniConnectPinnedResolverLease {
90
90
  }
91
91
  }
92
92
 
93
- private final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDelegate {
93
+ final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDataDelegate {
94
94
  private let hostname: String
95
95
  private let ip: String
96
96
  private let resolverLease: SniConnectPinnedResolverLease
97
-
98
- init(hostname: String, ip: String, resolverLease: SniConnectPinnedResolverLease) {
97
+ private weak var forwardingDataDelegate: URLSessionDataDelegate?
98
+
99
+ init(
100
+ hostname: String,
101
+ ip: String,
102
+ resolverLease: SniConnectPinnedResolverLease,
103
+ forwardingDataDelegate: URLSessionDataDelegate? = nil
104
+ ) {
99
105
  self.hostname = hostname
100
106
  self.ip = ip
101
107
  self.resolverLease = resolverLease
108
+ self.forwardingDataDelegate = forwardingDataDelegate
102
109
  }
103
110
 
104
111
  func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
@@ -110,6 +117,66 @@ private final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionD
110
117
  ("success", error == nil),
111
118
  ]))
112
119
  }
120
+
121
+ func urlSession(
122
+ _ session: URLSession,
123
+ task: URLSessionTask,
124
+ willPerformHTTPRedirection response: HTTPURLResponse,
125
+ newRequest request: URLRequest,
126
+ completionHandler: @escaping (URLRequest?) -> Void
127
+ ) {
128
+ if let forwardingDataDelegate {
129
+ forwardingDataDelegate.urlSession?(
130
+ session,
131
+ task: task,
132
+ willPerformHTTPRedirection: response,
133
+ newRequest: request,
134
+ completionHandler: completionHandler
135
+ )
136
+ } else {
137
+ completionHandler(nil)
138
+ }
139
+ }
140
+
141
+ func urlSession(
142
+ _ session: URLSession,
143
+ dataTask: URLSessionDataTask,
144
+ didReceive response: URLResponse,
145
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
146
+ ) {
147
+ forwardingDataDelegate?.urlSession?(
148
+ session,
149
+ dataTask: dataTask,
150
+ didReceive: response,
151
+ completionHandler: completionHandler
152
+ ) ?? completionHandler(
153
+ SniConnectSessionDelegatePolicy.responseDispositionWithoutForwardingDelegate
154
+ )
155
+ }
156
+
157
+ func urlSession(
158
+ _ session: URLSession,
159
+ dataTask: URLSessionDataTask,
160
+ didReceive data: Data
161
+ ) {
162
+ forwardingDataDelegate?.urlSession?(
163
+ session,
164
+ dataTask: dataTask,
165
+ didReceive: data
166
+ )
167
+ }
168
+
169
+ func urlSession(
170
+ _ session: URLSession,
171
+ task: URLSessionTask,
172
+ didCompleteWithError error: Error?
173
+ ) {
174
+ forwardingDataDelegate?.urlSession?(
175
+ session,
176
+ task: task,
177
+ didCompleteWithError: error
178
+ )
179
+ }
113
180
  }
114
181
 
115
182
  /// Core HTTPS client that enforces IP direct connection with SNI.
@@ -120,7 +187,7 @@ final class SniConnectClient {
120
187
  private var activeTasksByToken: [UUID: Task<Response, Error>] = [:]
121
188
  private var requestTokensById: [String: UUID] = [:]
122
189
  private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent)
123
- private let requestLimiter = SniConnectRequestLimiter.shared
190
+ private let requestLimiter = SniConnectRequestLimiter()
124
191
 
125
192
  private struct SessionKey: Hashable {
126
193
  let hostname: String
@@ -334,33 +401,10 @@ final class SniConnectClient {
334
401
  }
335
402
 
336
403
  private static func makeURLSession(for key: SessionKey) throws -> ManagedSession {
337
- let configuration = URLSessionConfiguration.default
338
- configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
339
- configuration.urlCache = nil
340
- configuration.httpCookieStorage = nil
341
- configuration.httpShouldSetCookies = false
342
- configuration.connectionProxyDictionary = [:]
343
- configuration.shouldUseExtendedBackgroundIdleMode = false
344
-
345
- let curlConfig = EMASCurlConfiguration.default()
346
- curlConfig.httpVersion = .HTTP1
347
- curlConfig.connectTimeoutInterval = 2.5
348
- curlConfig.enableBuiltInGzip = false
349
- curlConfig.enableBuiltInRedirection = false
350
- curlConfig.cacheEnabled = false
351
-
352
- // Enable full certificate validation for security.
353
- // The certificate is validated against the SNI hostname, not the IP, because
354
- // the custom DNS resolver only overrides address resolution — libcurl keeps the
355
- // original hostname for SNI and certificate CN/SAN matching.
356
- curlConfig.certificateValidationEnabled = true
357
- curlConfig.domainNameVerificationEnabled = true
358
- curlConfig.dnsResolver = try PinnedDNSResolverFactory.resolverClass(
404
+ let resources = try SniConnectPinnedTransport.makeResources(
359
405
  hostname: key.hostname,
360
406
  ip: key.ip
361
407
  )
362
-
363
- EMASCurlProtocol.install(into: configuration, with: curlConfig)
364
408
  SniConnectLog.info(SniConnectLog.event("sni_transport_config", [
365
409
  ("hostname", key.hostname),
366
410
  ("ipHash", SniConnectLog.shortHash(key.ip)),
@@ -371,15 +415,13 @@ final class SniConnectClient {
371
415
  ("followRedirects", false),
372
416
  ("cacheEnabled", false),
373
417
  ]))
374
- let resolverLease = SniConnectPinnedResolverLease(hostname: key.hostname, ip: key.ip)
375
- let delegate = SniConnectSessionInvalidationDelegate(
376
- hostname: key.hostname,
377
- ip: key.ip,
378
- resolverLease: resolverLease
379
- )
380
418
  return ManagedSession(
381
- session: URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil),
382
- resolverLease: resolverLease
419
+ session: URLSession(
420
+ configuration: resources.configuration,
421
+ delegate: resources.delegate,
422
+ delegateQueue: nil
423
+ ),
424
+ resolverLease: resources.resolverLease
383
425
  )
384
426
  }
385
427
 
@@ -510,10 +552,6 @@ final class SniConnectClient {
510
552
  ]))
511
553
  }
512
554
 
513
- func debugSnapshot(hostname: String, ip: String) -> SniConnectRequestLimiter.Snapshot {
514
- requestLimiter.snapshot(hostname: hostname, ip: ip)
515
- }
516
-
517
555
  /// Cancel a request by ID
518
556
  func cancelRequest(requestId: String) -> Bool {
519
557
  return tasksQueue.sync(flags: .barrier) { [weak self] in
@@ -620,24 +658,9 @@ final class SniConnectClient {
620
658
 
621
659
  let requestSlot: SniConnectRequestLimiter.Token
622
660
  do {
623
- // Admission wait and transport share one total wall-clock deadline.
624
- let limiter = requestLimiter
625
- let admissionTimeoutMilliseconds =
626
- config.effectiveTotalTimeout - Date().timeIntervalSince(startedAt) * 1_000.0
627
- guard admissionTimeoutMilliseconds > 0 else {
628
- throw SniConnectTimeout.deadlineExceeded
629
- }
630
- requestSlot = try await SniConnectWallClockDeadline.run(
631
- timeoutMilliseconds: admissionTimeoutMilliseconds
632
- ) {
633
- try await limiter.acquire(
634
- hostname: config.hostname,
635
- ip: config.ip,
636
- requestId: config.requestId
637
- )
638
- }
661
+ requestSlot = try requestLimiter.acquire(hostname: config.hostname, ip: config.ip)
639
662
  } catch {
640
- let sniError = SniConnectError.from(error)
663
+ let sniError = SniConnectError.resourceLimit("\(error)")
641
664
  SniConnectLog.error(SniConnectLog.event("sni_request_result", [
642
665
  ("result", "error"),
643
666
  ("code", sniError.code),
@@ -655,14 +678,6 @@ final class SniConnectClient {
655
678
  defer {
656
679
  requestSlot.release()
657
680
  }
658
- try Task.checkCancellation()
659
-
660
- let queueWaitMilliseconds = SniConnectLog.elapsedMs(since: startedAt)
661
- let remainingTimeoutMilliseconds =
662
- config.effectiveTotalTimeout - Double(queueWaitMilliseconds)
663
- guard remainingTimeoutMilliseconds > 0 else {
664
- throw SniConnectError.requestTimeout
665
- }
666
681
 
667
682
  let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath)
668
683
 
@@ -670,9 +685,8 @@ final class SniConnectClient {
670
685
  mutableRequest.httpMethod = method
671
686
 
672
687
  // Convert milliseconds to seconds for timeout values
673
- let totalTimeoutSeconds = remainingTimeoutMilliseconds / 1000.0
674
- let connectTimeoutSeconds =
675
- min(config.effectiveConnectTimeout, remainingTimeoutMilliseconds) / 1000.0
688
+ let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0
689
+ let connectTimeoutSeconds = config.effectiveConnectTimeout / 1000.0
676
690
 
677
691
  // URLRequest.timeoutInterval is not a full request deadline. Keep it aligned
678
692
  // with the caller timeout as a transport guard; the wall-clock deadline below
@@ -702,15 +716,12 @@ final class SniConnectClient {
702
716
  ("method", method),
703
717
  ("timeoutMs", Int(config.effectiveTotalTimeout)),
704
718
  ("connectTimeoutMs", Int(config.effectiveConnectTimeout)),
705
- ("queueWaitMs", queueWaitMilliseconds),
706
719
  ("headerCount", normalizedHeaders.count),
707
720
  ("bodyBytes", config.body?.data(using: .utf8)?.count ?? 0),
708
721
  ]))
709
722
 
710
723
  do {
711
- return try await SniConnectWallClockDeadline.run(
712
- timeoutMilliseconds: remainingTimeoutMilliseconds
713
- ) {
724
+ return try await SniConnectWallClockDeadline.run(timeoutMilliseconds: config.effectiveTotalTimeout) {
714
725
  let lease = try await self.sessionLease(for: config)
715
726
  defer {
716
727
  self.releaseSessionLease(lease)
@@ -1,5 +1,9 @@
1
1
  import Foundation
2
2
 
3
+ enum SniConnectSessionDelegatePolicy {
4
+ static let responseDispositionWithoutForwardingDelegate: URLSession.ResponseDisposition = .allow
5
+ }
6
+
3
7
  enum SniConnectCoreDiagnostics {
4
8
  static var warnSink: ((String) -> Void)?
5
9
 
@@ -0,0 +1,92 @@
1
+ import Foundation
2
+ import EMASCurl
3
+
4
+ struct SniConnectPinnedTransportResources {
5
+ let configuration: URLSessionConfiguration
6
+ let resolverLease: SniConnectPinnedResolverLease
7
+ let delegate: SniConnectSessionInvalidationDelegate
8
+ }
9
+
10
+ public final class SniConnectPinnedSession {
11
+ public let session: URLSession
12
+
13
+ init(resources: SniConnectPinnedTransportResources) {
14
+ session = URLSession(
15
+ configuration: resources.configuration,
16
+ delegate: resources.delegate,
17
+ delegateQueue: nil
18
+ )
19
+ }
20
+
21
+ public func close() {
22
+ session.finishTasksAndInvalidate()
23
+ }
24
+
25
+ deinit {
26
+ close()
27
+ }
28
+ }
29
+
30
+ public enum SniConnectPinnedTransport {
31
+ static func makeResources(
32
+ hostname: String,
33
+ ip: String,
34
+ dataDelegate: URLSessionDataDelegate? = nil
35
+ ) throws -> SniConnectPinnedTransportResources {
36
+ try SniConnectValidation.validateHostname(hostname)
37
+ try SniConnectValidation.validatePublicIP(ip)
38
+
39
+ let normalizedHostname = hostname.lowercased()
40
+ let configuration = URLSessionConfiguration.default
41
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
42
+ configuration.urlCache = nil
43
+ configuration.httpCookieStorage = nil
44
+ configuration.httpShouldSetCookies = false
45
+ configuration.connectionProxyDictionary = [:]
46
+ configuration.shouldUseExtendedBackgroundIdleMode = false
47
+
48
+ let curlConfig = EMASCurlConfiguration.default()
49
+ curlConfig.httpVersion = .HTTP1
50
+ curlConfig.connectTimeoutInterval = 2.5
51
+ curlConfig.enableBuiltInGzip = false
52
+ curlConfig.enableBuiltInRedirection = false
53
+ curlConfig.cacheEnabled = false
54
+ curlConfig.certificateValidationEnabled = true
55
+ curlConfig.domainNameVerificationEnabled = true
56
+ curlConfig.dnsResolver = try PinnedDNSResolverFactory.resolverClass(
57
+ hostname: normalizedHostname,
58
+ ip: ip
59
+ )
60
+ EMASCurlProtocol.install(into: configuration, with: curlConfig)
61
+
62
+ let resolverLease = SniConnectPinnedResolverLease(
63
+ hostname: normalizedHostname,
64
+ ip: ip
65
+ )
66
+ let delegate = SniConnectSessionInvalidationDelegate(
67
+ hostname: normalizedHostname,
68
+ ip: ip,
69
+ resolverLease: resolverLease,
70
+ forwardingDataDelegate: dataDelegate
71
+ )
72
+ return SniConnectPinnedTransportResources(
73
+ configuration: configuration,
74
+ resolverLease: resolverLease,
75
+ delegate: delegate
76
+ )
77
+ }
78
+
79
+ public static func makeSession(
80
+ hostname: String,
81
+ ip: String,
82
+ dataDelegate: URLSessionDataDelegate? = nil
83
+ ) throws -> SniConnectPinnedSession {
84
+ SniConnectPinnedSession(
85
+ resources: try makeResources(
86
+ hostname: hostname,
87
+ ip: ip,
88
+ dataDelegate: dataDelegate
89
+ )
90
+ )
91
+ }
92
+ }