@onekeyfe/react-native-sni-connect 3.0.81-alpha.7 → 3.0.81

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,6 +28,18 @@ 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
+
31
43
  @Test
32
44
  fun rejectsIpLiteralHostnames() {
33
45
  assertValidationFails { SniConnectValidation.validateHostname("93.184.216.34") }
@@ -117,8 +129,10 @@ class SniConnectValidationTest {
117
129
 
118
130
  @Test
119
131
  fun enforcesRequestIdTimeoutAndBodyLimits() {
132
+ SniConnectValidation.validateRequestId("界".repeat(42))
120
133
  assertValidationFails { SniConnectValidation.validateRequestId("") }
121
134
  assertValidationFails { SniConnectValidation.validateRequestId("x".repeat(129)) }
135
+ assertValidationFails { SniConnectValidation.validateRequestId("界".repeat(43)) }
122
136
  assertValidationFails { SniConnectValidation.validateRequestId("req\n1") }
123
137
  assertValidationFails { SniConnectValidation.validateTimeout(0) }
124
138
  assertValidationFails { SniConnectValidation.validateTimeout(120_001) }
@@ -184,32 +198,6 @@ class SniConnectValidationTest {
184
198
  SniConnectValidation.validateMethodBody("OPTIONS", null)
185
199
  }
186
200
 
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
-
213
201
  @Test
214
202
  fun classifiesSecurityFailuresAsFailClosedErrorCodes() {
215
203
  assertEquals(
@@ -244,6 +232,18 @@ class SniConnectValidationTest {
244
232
  "SNI_REQUEST_FAILED",
245
233
  classifySniFailureCode(IOException("connection reset")),
246
234
  )
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,6 +18,9 @@
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;
21
24
  - (void)isProxyActiveForUrl:(NSString *)url
22
25
  resolve:(RCTPromiseResolveBlock)resolve
23
26
  reject:(RCTPromiseRejectBlock)reject;
@@ -89,6 +92,16 @@ RCT_EXPORT_MODULE(SniConnect)
89
92
  [_implementation clearDNSCache:resolve reject:reject];
90
93
  }
91
94
 
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
+
92
105
  - (void)isProxyActiveForUrl:(NSString *)url
93
106
  resolve:(RCTPromiseResolveBlock)resolve
94
107
  reject:(RCTPromiseRejectBlock)reject {
@@ -124,6 +137,12 @@ RCT_EXPORT_METHOD(clearDNSCache:(RCTPromiseResolveBlock)resolver
124
137
  [_implementation clearDNSCache:resolver reject:rejecter];
125
138
  }
126
139
 
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
+
127
146
  RCT_EXPORT_METHOD(isProxyActiveForUrl:(NSString *)url
128
147
  resolver:(RCTPromiseResolveBlock)resolver
129
148
  rejecter:(RCTPromiseRejectBlock)rejecter) {
@@ -133,6 +133,35 @@ 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
+
136
165
  @objc
137
166
  public func isProxyActiveForUrl(
138
167
  _ url: String,
@@ -4,13 +4,13 @@ import UIKit
4
4
  import EMASCurl
5
5
 
6
6
  @objc(SniConnectPinnedDNSResolverBase)
7
- class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver {
7
+ private 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
- enum PinnedDNSResolverFactory {
13
+ private 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 @@ enum PinnedDNSResolverFactory {
54
54
  }
55
55
  }
56
56
 
57
- final class SniConnectPinnedResolverLease {
57
+ private 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,22 +90,15 @@ final class SniConnectPinnedResolverLease {
90
90
  }
91
91
  }
92
92
 
93
- final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDataDelegate {
93
+ private final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDelegate {
94
94
  private let hostname: String
95
95
  private let ip: String
96
96
  private let 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
- ) {
97
+
98
+ init(hostname: String, ip: String, resolverLease: SniConnectPinnedResolverLease) {
105
99
  self.hostname = hostname
106
100
  self.ip = ip
107
101
  self.resolverLease = resolverLease
108
- self.forwardingDataDelegate = forwardingDataDelegate
109
102
  }
110
103
 
111
104
  func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
@@ -117,64 +110,6 @@ final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDataDeleg
117
110
  ("success", error == nil),
118
111
  ]))
119
112
  }
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(.cancel)
153
- }
154
-
155
- func urlSession(
156
- _ session: URLSession,
157
- dataTask: URLSessionDataTask,
158
- didReceive data: Data
159
- ) {
160
- forwardingDataDelegate?.urlSession?(
161
- session,
162
- dataTask: dataTask,
163
- didReceive: data
164
- )
165
- }
166
-
167
- func urlSession(
168
- _ session: URLSession,
169
- task: URLSessionTask,
170
- didCompleteWithError error: Error?
171
- ) {
172
- forwardingDataDelegate?.urlSession?(
173
- session,
174
- task: task,
175
- didCompleteWithError: error
176
- )
177
- }
178
113
  }
179
114
 
180
115
  /// Core HTTPS client that enforces IP direct connection with SNI.
@@ -185,7 +120,7 @@ final class SniConnectClient {
185
120
  private var activeTasksByToken: [UUID: Task<Response, Error>] = [:]
186
121
  private var requestTokensById: [String: UUID] = [:]
187
122
  private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent)
188
- private let requestLimiter = SniConnectRequestLimiter()
123
+ private let requestLimiter = SniConnectRequestLimiter.shared
189
124
 
190
125
  private struct SessionKey: Hashable {
191
126
  let hostname: String
@@ -399,10 +334,33 @@ final class SniConnectClient {
399
334
  }
400
335
 
401
336
  private static func makeURLSession(for key: SessionKey) throws -> ManagedSession {
402
- let resources = try SniConnectPinnedTransport.makeResources(
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(
403
359
  hostname: key.hostname,
404
360
  ip: key.ip
405
361
  )
362
+
363
+ EMASCurlProtocol.install(into: configuration, with: curlConfig)
406
364
  SniConnectLog.info(SniConnectLog.event("sni_transport_config", [
407
365
  ("hostname", key.hostname),
408
366
  ("ipHash", SniConnectLog.shortHash(key.ip)),
@@ -413,13 +371,15 @@ final class SniConnectClient {
413
371
  ("followRedirects", false),
414
372
  ("cacheEnabled", false),
415
373
  ]))
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
+ )
416
380
  return ManagedSession(
417
- session: URLSession(
418
- configuration: resources.configuration,
419
- delegate: resources.delegate,
420
- delegateQueue: nil
421
- ),
422
- resolverLease: resources.resolverLease
381
+ session: URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil),
382
+ resolverLease: resolverLease
423
383
  )
424
384
  }
425
385
 
@@ -550,6 +510,10 @@ final class SniConnectClient {
550
510
  ]))
551
511
  }
552
512
 
513
+ func debugSnapshot(hostname: String, ip: String) -> SniConnectRequestLimiter.Snapshot {
514
+ requestLimiter.snapshot(hostname: hostname, ip: ip)
515
+ }
516
+
553
517
  /// Cancel a request by ID
554
518
  func cancelRequest(requestId: String) -> Bool {
555
519
  return tasksQueue.sync(flags: .barrier) { [weak self] in
@@ -656,9 +620,24 @@ final class SniConnectClient {
656
620
 
657
621
  let requestSlot: SniConnectRequestLimiter.Token
658
622
  do {
659
- requestSlot = try requestLimiter.acquire(hostname: config.hostname, ip: config.ip)
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
+ }
660
639
  } catch {
661
- let sniError = SniConnectError.resourceLimit("\(error)")
640
+ let sniError = SniConnectError.from(error)
662
641
  SniConnectLog.error(SniConnectLog.event("sni_request_result", [
663
642
  ("result", "error"),
664
643
  ("code", sniError.code),
@@ -676,6 +655,14 @@ final class SniConnectClient {
676
655
  defer {
677
656
  requestSlot.release()
678
657
  }
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
+ }
679
666
 
680
667
  let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath)
681
668
 
@@ -683,8 +670,9 @@ final class SniConnectClient {
683
670
  mutableRequest.httpMethod = method
684
671
 
685
672
  // Convert milliseconds to seconds for timeout values
686
- let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0
687
- let connectTimeoutSeconds = config.effectiveConnectTimeout / 1000.0
673
+ let totalTimeoutSeconds = remainingTimeoutMilliseconds / 1000.0
674
+ let connectTimeoutSeconds =
675
+ min(config.effectiveConnectTimeout, remainingTimeoutMilliseconds) / 1000.0
688
676
 
689
677
  // URLRequest.timeoutInterval is not a full request deadline. Keep it aligned
690
678
  // with the caller timeout as a transport guard; the wall-clock deadline below
@@ -714,12 +702,15 @@ final class SniConnectClient {
714
702
  ("method", method),
715
703
  ("timeoutMs", Int(config.effectiveTotalTimeout)),
716
704
  ("connectTimeoutMs", Int(config.effectiveConnectTimeout)),
705
+ ("queueWaitMs", queueWaitMilliseconds),
717
706
  ("headerCount", normalizedHeaders.count),
718
707
  ("bodyBytes", config.body?.data(using: .utf8)?.count ?? 0),
719
708
  ]))
720
709
 
721
710
  do {
722
- return try await SniConnectWallClockDeadline.run(timeoutMilliseconds: config.effectiveTotalTimeout) {
711
+ return try await SniConnectWallClockDeadline.run(
712
+ timeoutMilliseconds: remainingTimeoutMilliseconds
713
+ ) {
723
714
  let lease = try await self.sessionLease(for: config)
724
715
  defer {
725
716
  self.releaseSessionLease(lease)