@onekeyfe/react-native-sni-connect 6.7.0-2 → 6.7.0-20

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.
package/README.md CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  clearDNSCache,
41
41
  getDebugSnapshot,
42
42
  isProxyActiveForUrl,
43
+ uploadFile,
44
+ isUploadFileSupported,
43
45
  } from '@onekeyfe/react-native-sni-connect';
44
46
 
45
47
  const proxyActive = await isProxyActiveForUrl('https://example.com/api/v1/ping');
@@ -82,6 +84,9 @@ collapsed by the transport before this adapter can observe them. Full duplicate
82
84
  header preservation on iOS requires an EMASCurl version that exposes the raw
83
85
  response header list.
84
86
 
87
+ Check `isUploadFileSupported()` before uploading when JS can run with older native
88
+ binaries. `uploadFile()` rejects with `code: SNI_UNSUPPORTED` when unavailable.
89
+
85
90
  ## Specification
86
91
 
87
92
  The behavior of the SNI connect module is governed by a normative,
@@ -90,6 +95,9 @@ shared JS adapters, and any future platform) must conform to:
90
95
 
91
96
  **-> [OneKey SNI Connect Standard (OSCS)](https://github.com/OneKeyHQ/app-modules/blob/main/native-modules/react-native-sni-connect/SPEC.md)**
92
97
 
98
+ The [native upload contract](docs/SPEC.md) defines capability detection and
99
+ consumption-time file bounds.
100
+
93
101
  Any change to request validation, destination pinning, TLS validation, redirect
94
102
  handling, cancellation, response shape, or cache behavior must be checked
95
103
  against OSCS.
@@ -21,6 +21,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
21
21
  import okhttp3.Response
22
22
  import okhttp3.ResponseBody
23
23
  import okio.Buffer
24
+ import java.io.File
24
25
  import java.io.IOException
25
26
  import java.io.InterruptedIOException
26
27
  import java.net.Proxy
@@ -191,6 +192,24 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
191
192
  }
192
193
  }
193
194
 
195
+ override fun uploadFile(config: ReadableMap, promise: Promise) {
196
+ try {
197
+ val requestConfig = config.toUploadFileConfig()
198
+ performRequest(requestConfig, promise)
199
+ } catch (error: Exception) {
200
+ SniConnectLogger.error(
201
+ SniConnectLogger.event(
202
+ "sni_request_result",
203
+ "result" to "error",
204
+ "code" to "SNI_INVALID_CONFIG",
205
+ "nativeErrorClass" to error.javaClass.simpleName,
206
+ "stage" to "parse_upload_config",
207
+ ),
208
+ )
209
+ promise.reject("SNI_INVALID_CONFIG", error.message, error)
210
+ }
211
+ }
212
+
194
213
  @ReactMethod
195
214
  override fun cancelRequest(requestId: String, promise: Promise) {
196
215
  val request = synchronized(activeCallsLock) {
@@ -301,7 +320,8 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
301
320
  "method" to config.method,
302
321
  "timeoutMs" to config.timeoutMillis,
303
322
  "headerCount" to config.headers.size,
304
- "bodyBytes" to (config.body?.toByteArray(StandardCharsets.UTF_8)?.size ?: 0),
323
+ "bodyBytes" to config.bodyBytes(),
324
+ "bodySource" to if (config.bodyFile != null) "file" else "string",
305
325
  ),
306
326
  )
307
327
 
@@ -349,6 +369,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
349
369
  "ipFamily" to SniConnectLogger.ipFamily(config.ip),
350
370
  "method" to config.method,
351
371
  "timeoutMs" to config.timeoutMillis,
372
+ "bodyBytes" to config.bodyBytes(),
352
373
  "responseBytes" to bodyString.toByteArray(StandardCharsets.UTF_8).size,
353
374
  "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
354
375
  )
@@ -581,13 +602,13 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
581
602
  builder.header("Accept-Encoding", "identity")
582
603
 
583
604
  val method = config.method
584
- val requestBody = config.body?.let { bodyContent ->
585
- val mediaType = config.headers.entries
586
- .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
587
- ?.value
588
- ?.toMediaTypeOrNull()
589
- bodyContent.toRequestBody(mediaType)
590
- }
605
+ val mediaType = config.headers.entries
606
+ .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
607
+ ?.value
608
+ ?.toMediaTypeOrNull()
609
+ // Enforce the body bound when OkHttp consumes the file, after admission.
610
+ val requestBody = config.bodyFile?.let { SniUploadFileRequestBody(it, mediaType) }
611
+ ?: config.body?.toRequestBody(mediaType)
591
612
 
592
613
  when (method) {
593
614
  "GET" -> builder.get()
@@ -713,6 +734,67 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
713
734
  )
714
735
  }
715
736
 
737
+ /** Uploads may only read files from this app's own storage. */
738
+ private fun uploadFileAllowedRoots(): List<File> {
739
+ val context = reactApplicationContext
740
+ return listOfNotNull(
741
+ File(context.applicationInfo.dataDir),
742
+ context.filesDir,
743
+ context.cacheDir,
744
+ context.externalCacheDir,
745
+ context.getExternalFilesDir(null),
746
+ )
747
+ }
748
+
749
+ private fun ReadableMap.toUploadFileConfig(): RequestConfig {
750
+ if (hasKey("body") && !isNull("body")) {
751
+ throw IllegalArgumentException("uploadFile does not accept body")
752
+ }
753
+ val filePath = if (hasKey("filePath") && !isNull("filePath")) getString("filePath") else null
754
+ if (filePath.isNullOrEmpty()) {
755
+ throw IllegalArgumentException("filePath is required")
756
+ }
757
+ val rawHeadersMap = if (hasKey("headers") && !isNull("headers")) {
758
+ getMap("headers")?.toHashMap()
759
+ ?.mapValues { (_, value) -> value?.toString() ?: "" }
760
+ ?: emptyMap()
761
+ } else {
762
+ emptyMap()
763
+ }
764
+ val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) {
765
+ SniConnectValidation.parseTimeoutMillis(getDouble("timeout"))
766
+ } else {
767
+ 30_000L
768
+ }
769
+ val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null
770
+ val rawIp = getString("ip") ?: throw IllegalArgumentException("ip is required")
771
+ val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
772
+ val method = getString("method") ?: "POST"
773
+ val path = getString("path") ?: "/"
774
+
775
+ SniConnectValidation.validateRequestId(requestId)
776
+ val ip = SniConnectValidation.canonicalizePublicIp(rawIp)
777
+ SniConnectValidation.validateHostname(hostname)
778
+ val headersMap = SniConnectValidation.normalizeHeaders(rawHeadersMap)
779
+ val normalizedMethod = SniConnectValidation.normalizeMethod(method)
780
+ SniConnectValidation.validateUploadMethod(normalizedMethod)
781
+ val normalizedPath = SniConnectValidation.normalizePath(path)
782
+ SniConnectValidation.validateTimeout(timeoutMillis)
783
+ val bodyFile = SniConnectValidation.resolveUploadFile(filePath, uploadFileAllowedRoots())
784
+
785
+ return RequestConfig(
786
+ requestId = requestId,
787
+ ip = ip,
788
+ hostname = hostname,
789
+ method = normalizedMethod,
790
+ path = normalizedPath,
791
+ headers = headersMap,
792
+ body = null,
793
+ timeoutMillis = timeoutMillis,
794
+ bodyFile = bodyFile,
795
+ )
796
+ }
797
+
716
798
  private data class RequestConfig(
717
799
  val requestId: String?,
718
800
  val ip: String,
@@ -722,7 +804,12 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
722
804
  val headers: Map<String, String>,
723
805
  val body: String?,
724
806
  val timeoutMillis: Long,
725
- )
807
+ // Set only by uploadFile(); already resolved inside the app sandbox.
808
+ val bodyFile: File? = null,
809
+ ) {
810
+ fun bodyBytes(): Long =
811
+ bodyFile?.length() ?: (body?.toByteArray(StandardCharsets.UTF_8)?.size?.toLong() ?: 0L)
812
+ }
726
813
 
727
814
  private data class HeaderMaps(
728
815
  val singleValueHeaders: Map<String, String>,
@@ -1,6 +1,9 @@
1
1
  package com.sniconnect
2
2
 
3
+ import java.io.File
4
+ import java.io.IOException
3
5
  import java.net.Inet6Address
6
+ import java.net.URI
4
7
  import java.net.InetAddress
5
8
  import java.nio.charset.StandardCharsets
6
9
  import java.util.Locale
@@ -22,6 +25,7 @@ internal object SniConnectValidation {
22
25
  const val MAX_PATH_BYTES = 8 * 1024
23
26
  const val MAX_REQUEST_BODY_BYTES = 1024 * 1024
24
27
  const val MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024L
28
+ const val MAX_UPLOAD_FILE_BYTES = 20 * 1024 * 1024L
25
29
  const val MAX_HEADER_COUNT = 64
26
30
  const val MAX_HEADER_NAME_BYTES = 128
27
31
  const val MAX_HEADER_VALUE_BYTES = 8 * 1024
@@ -94,6 +98,70 @@ internal object SniConnectValidation {
94
98
  }
95
99
  }
96
100
 
101
+ /** File uploads always carry a body, so only body-bearing write methods apply. */
102
+ fun validateUploadMethod(method: String) {
103
+ if (method != "POST" && method != "PUT") {
104
+ throw ValidationException("Invalid upload method: $method")
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Resolve a caller-supplied upload path to a regular file inside one of
110
+ * [allowedRoots] (the app's own storage). Canonicalization resolves symlinks
111
+ * before the containment check so a link cannot point the upload elsewhere.
112
+ */
113
+ fun resolveUploadFile(rawPath: String, allowedRoots: List<File>): File {
114
+ if (rawPath.isEmpty() || containsControlChars(rawPath)) {
115
+ throw ValidationException("Invalid file path")
116
+ }
117
+ val file = when {
118
+ rawPath.startsWith("file:") -> {
119
+ val uri = try {
120
+ URI(rawPath)
121
+ } catch (error: Exception) {
122
+ throw ValidationException("Invalid file URL")
123
+ }
124
+ val path = uri.path
125
+ if (uri.scheme != "file" || path.isNullOrEmpty()) {
126
+ throw ValidationException("Invalid file URL")
127
+ }
128
+ File(path)
129
+ }
130
+ rawPath.startsWith("/") -> File(rawPath)
131
+ else -> throw ValidationException("File path must be absolute")
132
+ }
133
+
134
+ val canonicalFile = try {
135
+ file.canonicalFile
136
+ } catch (error: IOException) {
137
+ throw ValidationException("File path cannot be resolved")
138
+ }
139
+ val canonicalPath = canonicalFile.path
140
+ val isInsideAllowedRoot = allowedRoots.any { root ->
141
+ val rootPath = try {
142
+ root.canonicalPath
143
+ } catch (error: IOException) {
144
+ return@any false
145
+ }
146
+ val prefix = if (rootPath.endsWith(File.separator)) rootPath else rootPath + File.separator
147
+ canonicalPath.startsWith(prefix)
148
+ }
149
+ if (!isInsideAllowedRoot) {
150
+ throw ValidationException("File is outside the app sandbox")
151
+ }
152
+ if (!canonicalFile.isFile || !canonicalFile.canRead()) {
153
+ throw ValidationException("Not a readable regular file")
154
+ }
155
+ val sizeBytes = canonicalFile.length()
156
+ if (sizeBytes <= 0L) {
157
+ throw ValidationException("File is empty")
158
+ }
159
+ if (sizeBytes > MAX_UPLOAD_FILE_BYTES) {
160
+ throw ValidationException("File is too large")
161
+ }
162
+ return canonicalFile
163
+ }
164
+
97
165
  fun normalizeMethod(method: String): String {
98
166
  if (containsControlChars(method)) {
99
167
  throw ValidationException("Invalid method: $method")
@@ -1,5 +1,7 @@
1
1
  package com.sniconnect
2
2
 
3
+ import java.io.File
4
+ import java.io.IOException
3
5
  import java.net.InetAddress
4
6
  import java.net.Proxy
5
7
  import java.net.UnknownHostException
@@ -8,8 +10,13 @@ import java.util.concurrent.TimeUnit
8
10
  import okhttp3.ConnectionPool
9
11
  import okhttp3.Dispatcher
10
12
  import okhttp3.Dns
13
+ import okhttp3.MediaType
11
14
  import okhttp3.OkHttpClient
12
15
  import okhttp3.Protocol
16
+ import okhttp3.RequestBody
17
+ import okio.Buffer
18
+ import okio.BufferedSink
19
+ import okio.source
13
20
  import javax.net.ssl.HttpsURLConnection
14
21
 
15
22
  object SniPinnedTransport {
@@ -56,3 +63,39 @@ object SniPinnedTransport {
56
63
  }
57
64
  }
58
65
  }
66
+
67
+ /** Reads from disk only during transmission, with a fixed, bounded body length. */
68
+ internal class SniUploadFileRequestBody(
69
+ private val file: File,
70
+ private val mediaType: MediaType?,
71
+ ) : RequestBody() {
72
+ private val length = file.length().also {
73
+ if (it <= 0 || it > SniConnectValidation.MAX_UPLOAD_FILE_BYTES) {
74
+ throw IOException("Upload file size is out of range")
75
+ }
76
+ }
77
+
78
+ override fun contentType(): MediaType? = mediaType
79
+
80
+ override fun contentLength(): Long = length
81
+
82
+ override fun writeTo(sink: BufferedSink) {
83
+ file.source().use { source ->
84
+ val buffer = Buffer()
85
+ var totalBytes = 0L
86
+ while (true) {
87
+ // One extra byte detects growth without ever writing beyond the bound.
88
+ val read = source.read(buffer, minOf(8 * 1024L, length - totalBytes + 1))
89
+ if (read == -1L) break
90
+ if (totalBytes + read > length) {
91
+ throw IOException("Upload file grew during transmission")
92
+ }
93
+ sink.write(buffer, read)
94
+ totalBytes += read
95
+ }
96
+ if (totalBytes != length) {
97
+ throw IOException("Upload file was truncated during transmission")
98
+ }
99
+ }
100
+ }
101
+ }
@@ -3,8 +3,17 @@ package com.sniconnect
3
3
  import org.junit.Assert.assertEquals
4
4
  import org.junit.Assert.assertFalse
5
5
  import org.junit.Assert.assertTrue
6
+ import org.junit.Assert.assertThrows
7
+ import okio.Buffer
8
+ import okio.ForwardingSink
9
+ import okio.buffer
10
+ import org.junit.Rule
6
11
  import org.junit.Test
12
+ import org.junit.rules.TemporaryFolder
13
+ import java.io.File
7
14
  import java.io.IOException
15
+ import java.io.RandomAccessFile
16
+ import java.nio.file.Files
8
17
  import java.io.InterruptedIOException
9
18
  import java.net.SocketTimeoutException
10
19
  import java.net.UnknownHostException
@@ -14,6 +23,121 @@ import javax.net.ssl.SSLPeerUnverifiedException
14
23
 
15
24
  class SniConnectValidationTest {
16
25
 
26
+ @get:Rule
27
+ val tempFolder = TemporaryFolder()
28
+
29
+ @Test
30
+ fun uploadMethodAllowsOnlyPostAndPut() {
31
+ SniConnectValidation.validateUploadMethod("POST")
32
+ SniConnectValidation.validateUploadMethod("PUT")
33
+ listOf("GET", "HEAD", "PATCH", "DELETE", "OPTIONS").forEach { method ->
34
+ assertValidationFails { SniConnectValidation.validateUploadMethod(method) }
35
+ }
36
+ }
37
+
38
+ @Test
39
+ fun resolveUploadFileAcceptsSandboxFileByPathAndFileUrl() {
40
+ val root = tempFolder.newFolder("sandbox")
41
+ val file = File(root, "logs.zip").apply { writeBytes(byteArrayOf(0x50, 0x4B, 3, 4, -1)) }
42
+
43
+ val byPath = SniConnectValidation.resolveUploadFile(file.path, listOf(root))
44
+ assertEquals(file.canonicalPath, byPath.path)
45
+ val byUrl = SniConnectValidation.resolveUploadFile(file.toURI().toString(), listOf(root))
46
+ assertEquals(file.canonicalPath, byUrl.path)
47
+ val byTripleSlashUrl = SniConnectValidation.resolveUploadFile("file://${file.path}", listOf(root))
48
+ assertEquals(file.canonicalPath, byTripleSlashUrl.path)
49
+ }
50
+
51
+ @Test
52
+ fun resolveUploadFileRejectsInvalidOrOutOfSandboxFiles() {
53
+ val root = tempFolder.newFolder("sandbox")
54
+ val outside = tempFolder.newFolder("outside")
55
+ val outsideFile = File(outside, "secret.bin").apply { writeBytes(byteArrayOf(1, 2, 3)) }
56
+ val emptyFile = File(root, "empty.zip").apply { writeBytes(ByteArray(0)) }
57
+ val link = File(root, "link.zip")
58
+ Files.createSymbolicLink(link.toPath(), outsideFile.toPath())
59
+
60
+ listOf(
61
+ "",
62
+ "relative/logs.zip",
63
+ "https://example.com/logs.zip",
64
+ outsideFile.path,
65
+ "${root.path}/../outside/secret.bin",
66
+ link.path,
67
+ emptyFile.path,
68
+ root.path,
69
+ File(root, "missing.zip").path,
70
+ "${root.path}/logs\n.zip",
71
+ ).forEach { path ->
72
+ assertValidationFails { SniConnectValidation.resolveUploadFile(path, listOf(root)) }
73
+ }
74
+ }
75
+
76
+ @Test
77
+ fun resolveUploadFileRejectsFilesOverTheSizeLimit() {
78
+ val root = tempFolder.newFolder("sandbox")
79
+ val file = File(root, "big.zip")
80
+ RandomAccessFile(file, "rw").use { it.setLength(SniConnectValidation.MAX_UPLOAD_FILE_BYTES + 1) }
81
+
82
+ assertValidationFails { SniConnectValidation.resolveUploadFile(file.path, listOf(root)) }
83
+ }
84
+
85
+ @Test
86
+ fun uploadBodyStreamsExactLimitBinaryData() {
87
+ val file = tempFolder.newFile("exact.zip")
88
+ val expected = ByteArray(SniConnectValidation.MAX_UPLOAD_FILE_BYTES.toInt()) { 0xFF.toByte() }
89
+ file.writeBytes(expected)
90
+ val body = SniUploadFileRequestBody(file, null)
91
+ val sink = Buffer()
92
+ body.writeTo(sink)
93
+ assertEquals(expected.size.toLong(), body.contentLength())
94
+ assertTrue(expected.contentEquals(sink.readByteArray()))
95
+ }
96
+
97
+ @Test
98
+ fun uploadBodyRejectsGrowthAfterValidation() {
99
+ val root = tempFolder.newFolder("sandbox")
100
+ val file = File(root, "growing.zip").apply { writeBytes(byteArrayOf(1)) }
101
+ val validated = SniConnectValidation.resolveUploadFile(file.path, listOf(root))
102
+ val body = SniUploadFileRequestBody(validated, null)
103
+ RandomAccessFile(file, "rw").use { it.setLength(SniConnectValidation.MAX_UPLOAD_FILE_BYTES + 1) }
104
+ val sink = Buffer()
105
+ assertThrows(IOException::class.java) { body.writeTo(sink) }
106
+ assertTrue(sink.size <= body.contentLength())
107
+ assertThrows(IOException::class.java) { SniUploadFileRequestBody(validated, null) }
108
+ }
109
+
110
+ @Test
111
+ fun uploadBodyRejectsGrowthDuringTransmission() {
112
+ val file = tempFolder.newFile("growing-during-write.zip")
113
+ file.writeBytes(ByteArray(16 * 1024) { 1 })
114
+ val body = SniUploadFileRequestBody(file, null)
115
+ val output = Buffer()
116
+ var appended = false
117
+ val sink = object : ForwardingSink(output) {
118
+ override fun write(source: Buffer, byteCount: Long) {
119
+ super.write(source, byteCount)
120
+ if (!appended) {
121
+ file.appendBytes(byteArrayOf(2))
122
+ appended = true
123
+ }
124
+ }
125
+ }.buffer()
126
+ assertThrows(IOException::class.java) { body.writeTo(sink) }
127
+ assertTrue(appended)
128
+ assertTrue(output.size <= body.contentLength())
129
+ }
130
+
131
+ @Test
132
+ fun uploadBodyRejectsTruncationAfterValidation() {
133
+ val file = tempFolder.newFile("truncated.zip").apply { writeBytes(byteArrayOf(1, 2)) }
134
+ val body = SniUploadFileRequestBody(file, null)
135
+ file.writeBytes(byteArrayOf(1))
136
+ assertThrows(IOException::class.java) { body.writeTo(Buffer()) }
137
+ file.writeBytes(byteArrayOf())
138
+ assertThrows(IOException::class.java) { body.writeTo(Buffer()) }
139
+ }
140
+
17
141
  @Test
18
142
  fun acceptsValidRequestBoundaryValues() {
19
143
  SniConnectValidation.validateRequestId("req-1")
package/ios/SniConnect.mm CHANGED
@@ -13,6 +13,9 @@
13
13
  - (void)request:(NSDictionary *)config
14
14
  resolve:(RCTPromiseResolveBlock)resolve
15
15
  reject:(RCTPromiseRejectBlock)reject;
16
+ - (void)uploadFile:(NSDictionary *)config
17
+ resolve:(RCTPromiseResolveBlock)resolve
18
+ reject:(RCTPromiseRejectBlock)reject;
16
19
  - (void)cancelRequest:(NSString *)requestId
17
20
  resolve:(RCTPromiseResolveBlock)resolve
18
21
  reject:(RCTPromiseRejectBlock)reject;
@@ -82,6 +85,24 @@ RCT_EXPORT_MODULE(SniConnect)
82
85
  [_implementation request:configDict resolve:resolve reject:reject];
83
86
  }
84
87
 
88
+ - (void)uploadFile:(JS::NativeSniConnect::NativeSniConnectUploadFileRequest &)config
89
+ resolve:(RCTPromiseResolveBlock)resolve
90
+ reject:(RCTPromiseRejectBlock)reject {
91
+ NSMutableDictionary *configDict = [NSMutableDictionary dictionary];
92
+ configDict[@"ip"] = config.ip() ?: @"";
93
+ configDict[@"hostname"] = config.hostname() ?: @"";
94
+ configDict[@"method"] = config.method() ?: @"POST";
95
+ configDict[@"path"] = config.path() ?: @"/";
96
+ configDict[@"headers"] = config.headers() ?: @{};
97
+ configDict[@"timeout"] = @(config.timeout());
98
+ configDict[@"filePath"] = config.filePath() ?: @"";
99
+ if (config.requestId()) {
100
+ configDict[@"requestId"] = config.requestId();
101
+ }
102
+
103
+ [_implementation uploadFile:configDict resolve:resolve reject:reject];
104
+ }
105
+
85
106
  - (void)cancelRequest:(NSString *)requestId
86
107
  resolve:(RCTPromiseResolveBlock)resolve
87
108
  reject:(RCTPromiseRejectBlock)reject {
@@ -127,6 +148,12 @@ RCT_EXPORT_METHOD(request:(NSDictionary *)config
127
148
  [_implementation request:config resolve:resolve reject:reject];
128
149
  }
129
150
 
151
+ RCT_EXPORT_METHOD(uploadFile:(NSDictionary *)config
152
+ resolver:(RCTPromiseResolveBlock)resolve
153
+ rejecter:(RCTPromiseRejectBlock)reject) {
154
+ [_implementation uploadFile:config resolve:resolve reject:reject];
155
+ }
156
+
130
157
  RCT_EXPORT_METHOD(cancelRequest:(NSString *)requestId
131
158
  resolver:(RCTPromiseResolveBlock)resolve
132
159
  rejecter:(RCTPromiseRejectBlock)reject) {
@@ -41,6 +41,26 @@ final class SniConnectImpl: NSObject {
41
41
  }
42
42
  }
43
43
 
44
+ @objc
45
+ public func uploadFile(
46
+ _ config: NSDictionary,
47
+ resolve: @escaping RCTPromiseResolveBlock,
48
+ reject: @escaping RCTPromiseRejectBlock
49
+ ) {
50
+ do {
51
+ let parsed = try Self.parseUploadFileDictionary(config)
52
+ handleRequest(config: parsed, resolve: resolve, reject: reject)
53
+ } catch {
54
+ SniConnectLog.error(SniConnectLog.event("sni_request_result", [
55
+ ("result", "error"),
56
+ ("code", "SNI_INVALID_CONFIG"),
57
+ ("nativeErrorClass", String(describing: type(of: error))),
58
+ ("stage", "parse_upload_config"),
59
+ ]))
60
+ reject("SNI_INVALID_CONFIG", "\(error)", error)
61
+ }
62
+ }
63
+
44
64
  private func handleRequest(
45
65
  config: SniConnectClient.RequestConfig,
46
66
  resolve: @escaping RCTPromiseResolveBlock,
@@ -213,6 +233,45 @@ final class SniConnectImpl: NSObject {
213
233
  )
214
234
  }
215
235
 
236
+ /// Uploads may only read files from this app's own container.
237
+ private static var uploadFileAllowedRoots: [URL] {
238
+ [
239
+ URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true),
240
+ URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true),
241
+ ]
242
+ }
243
+
244
+ private static func parseUploadFileDictionary(
245
+ _ dictionary: NSDictionary
246
+ ) throws -> SniConnectClient.RequestConfig {
247
+ guard dictionary["body"] == nil || dictionary["body"] is NSNull else {
248
+ throw SniConnectError.invalidConfig("uploadFile does not accept body")
249
+ }
250
+ guard let filePath = dictionary["filePath"] as? String, !filePath.isEmpty else {
251
+ throw SniConnectError.invalidConfig("Missing filePath")
252
+ }
253
+ let base = try parseDictionary(dictionary)
254
+ let method = try SniConnectValidation.normalizeMethod(base.method)
255
+ try SniConnectValidation.validateUploadMethod(method)
256
+ let resolvedFile = try SniConnectValidation.resolveUploadFile(
257
+ filePath,
258
+ allowedRoots: uploadFileAllowedRoots
259
+ )
260
+ return SniConnectClient.RequestConfig(
261
+ requestId: base.requestId,
262
+ ip: base.ip,
263
+ hostname: base.hostname,
264
+ method: method,
265
+ path: base.path,
266
+ headers: base.headers,
267
+ body: nil,
268
+ timeout: base.timeout,
269
+ bodyFileURL: resolvedFile.url,
270
+ connectTimeout: base.connectTimeout,
271
+ totalTimeout: base.totalTimeout
272
+ )
273
+ }
274
+
216
275
  private static func validate(_ config: SniConnectClient.RequestConfig) throws {
217
276
  try SniConnectValidation.validatePublicIP(config.ip)
218
277
  try SniConnectValidation.validateHostname(config.hostname)
@@ -221,6 +280,10 @@ final class SniConnectImpl: NSObject {
221
280
  _ = try SniConnectValidation.normalizePath(config.path)
222
281
  try SniConnectValidation.validateRequestId(config.requestId)
223
282
  try SniConnectValidation.validateTimeout(config.effectiveTotalTimeout)
283
+ if config.bodyFileURL != nil {
284
+ try SniConnectValidation.validateUploadMethod(method)
285
+ return
286
+ }
224
287
  try SniConnectValidation.validateBody(config.body)
225
288
  try SniConnectValidation.validateMethodBody(method: method, body: config.body)
226
289
  }
@@ -259,6 +259,8 @@ final class SniConnectClient {
259
259
  let headers: [String: String]
260
260
  let body: String?
261
261
  let timeout: TimeInterval
262
+ // Set only by uploadFile(); already resolved inside the app sandbox.
263
+ var bodyFileURL: URL? = nil
262
264
 
263
265
  // Advanced timeout configurations
264
266
  let connectTimeout: TimeInterval? // Connection establishment timeout
@@ -678,8 +680,15 @@ final class SniConnectClient {
678
680
  method = try SniConnectValidation.normalizeMethod(config.method)
679
681
  normalizedPath = try SniConnectValidation.normalizePath(config.path)
680
682
  try SniConnectValidation.validateTimeout(config.effectiveTotalTimeout)
681
- try SniConnectValidation.validateBody(config.body)
682
- try SniConnectValidation.validateMethodBody(method: method, body: config.body)
683
+ if config.bodyFileURL != nil {
684
+ try SniConnectValidation.validateUploadMethod(method)
685
+ if config.body != nil {
686
+ throw SniConnectValidation.ValidationError.invalidBody
687
+ }
688
+ } else {
689
+ try SniConnectValidation.validateBody(config.body)
690
+ try SniConnectValidation.validateMethodBody(method: method, body: config.body)
691
+ }
683
692
  } catch {
684
693
  let sniError = SniConnectError.invalidConfig("\(error)")
685
694
  SniConnectLog.error(SniConnectLog.event("sni_request_result", [
@@ -763,8 +772,20 @@ final class SniConnectClient {
763
772
  mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host")
764
773
  mutableRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
765
774
 
766
- if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) {
775
+ var bodyBytes = 0
776
+ if let bodyFileURL = config.bodyFileURL {
777
+ let fileData: Data
778
+ do {
779
+ // Bound consumption even if the file grew while waiting for admission.
780
+ fileData = try SniConnectValidation.readUploadFile(bodyFileURL)
781
+ } catch {
782
+ throw SniConnectError.invalidConfig("Upload file is not readable")
783
+ }
784
+ mutableRequest.httpBody = fileData
785
+ bodyBytes = fileData.count
786
+ } else if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) {
767
787
  mutableRequest.httpBody = bodyData
788
+ bodyBytes = bodyData.count
768
789
  }
769
790
 
770
791
  // Per-request connect timeout (avoids the process-global setter race).
@@ -781,7 +802,8 @@ final class SniConnectClient {
781
802
  ("connectTimeoutMs", Int(config.effectiveConnectTimeout)),
782
803
  ("queueWaitMs", queueWaitMilliseconds),
783
804
  ("headerCount", normalizedHeaders.count),
784
- ("bodyBytes", config.body?.data(using: .utf8)?.count ?? 0),
805
+ ("bodyBytes", bodyBytes),
806
+ ("bodySource", config.bodyFileURL != nil ? "file" : "string"),
785
807
  ]))
786
808
 
787
809
  do {
@@ -817,6 +839,7 @@ final class SniConnectClient {
817
839
  ("ipFamily", SniConnectLog.ipFamily(config.ip)),
818
840
  ("method", method),
819
841
  ("timeoutMs", Int(config.effectiveTotalTimeout)),
842
+ ("bodyBytes", bodyBytes),
820
843
  ("responseBytes", data.count),
821
844
  ("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
822
845
  ])
@@ -18,6 +18,7 @@ enum SniConnectValidation {
18
18
  case invalidRequestId(String)
19
19
  case invalidTimeout(Double)
20
20
  case invalidBody
21
+ case invalidUploadFile(String)
21
22
  case resourceLimit(String)
22
23
  }
23
24
 
@@ -26,6 +27,7 @@ enum SniConnectValidation {
26
27
  static let maxPathBytes = 8 * 1024
27
28
  static let maxRequestBodyBytes = 1024 * 1024
28
29
  static let maxResponseBodyBytes = 10 * 1024 * 1024
30
+ static let maxUploadFileBytes = 20 * 1024 * 1024
29
31
  static let maxHeaderCount = 64
30
32
  static let maxHeaderNameBytes = 128
31
33
  static let maxHeaderValueBytes = 8 * 1024
@@ -88,6 +90,75 @@ enum SniConnectValidation {
88
90
  }
89
91
  }
90
92
 
93
+ /// File uploads always carry a body, so only body-bearing write methods apply.
94
+ static func validateUploadMethod(_ method: String) throws {
95
+ if method != "POST" && method != "PUT" {
96
+ throw ValidationError.invalidMethod(method)
97
+ }
98
+ }
99
+
100
+ /// Resolve a caller-supplied upload path to a regular file inside one of
101
+ /// `allowedRoots` (the app's own sandbox). Symlinks are resolved before the
102
+ /// containment check so a link cannot point the upload at another file.
103
+ static func resolveUploadFile(
104
+ _ rawPath: String,
105
+ allowedRoots: [URL]
106
+ ) throws -> (url: URL, sizeBytes: Int) {
107
+ if rawPath.isEmpty || containsControlCharacters(rawPath) {
108
+ throw ValidationError.invalidUploadFile("Invalid file path")
109
+ }
110
+ let fileURL: URL
111
+ if rawPath.hasPrefix("file:") {
112
+ guard let parsed = URL(string: rawPath), parsed.isFileURL else {
113
+ throw ValidationError.invalidUploadFile("Invalid file URL")
114
+ }
115
+ fileURL = parsed
116
+ } else if rawPath.hasPrefix("/") {
117
+ fileURL = URL(fileURLWithPath: rawPath)
118
+ } else {
119
+ throw ValidationError.invalidUploadFile("File path must be absolute")
120
+ }
121
+
122
+ let resolvedPath = fileURL.standardizedFileURL.resolvingSymlinksInPath().path
123
+ let isInsideAllowedRoot = allowedRoots.contains { root in
124
+ let rootPath = root.standardizedFileURL.resolvingSymlinksInPath().path
125
+ let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/"
126
+ return resolvedPath.hasPrefix(prefix)
127
+ }
128
+ if !isInsideAllowedRoot {
129
+ throw ValidationError.invalidUploadFile("File is outside the app sandbox")
130
+ }
131
+
132
+ let attributes: [FileAttributeKey: Any]
133
+ do {
134
+ attributes = try FileManager.default.attributesOfItem(atPath: resolvedPath)
135
+ } catch {
136
+ throw ValidationError.invalidUploadFile("File is not readable")
137
+ }
138
+ guard (attributes[.type] as? FileAttributeType) == .typeRegular else {
139
+ throw ValidationError.invalidUploadFile("Not a regular file")
140
+ }
141
+ let sizeBytes = (attributes[.size] as? NSNumber)?.intValue ?? 0
142
+ if sizeBytes <= 0 {
143
+ throw ValidationError.invalidUploadFile("File is empty")
144
+ }
145
+ if sizeBytes > maxUploadFileBytes {
146
+ throw ValidationError.invalidUploadFile("File is too large")
147
+ }
148
+ return (URL(fileURLWithPath: resolvedPath), sizeBytes)
149
+ }
150
+
151
+ /// Bound the actual read as well as the earlier path/metadata validation.
152
+ static func readUploadFile(_ url: URL) throws -> Data {
153
+ let handle = try FileHandle(forReadingFrom: url)
154
+ defer { try? handle.close() }
155
+ let data = try handle.read(upToCount: maxUploadFileBytes + 1) ?? Data()
156
+ guard !data.isEmpty, data.count <= maxUploadFileBytes else {
157
+ throw ValidationError.invalidUploadFile("File size is out of range")
158
+ }
159
+ return data
160
+ }
161
+
91
162
  /// Validate and uppercase the HTTP method.
92
163
  static func normalizeMethod(_ method: String) throws -> String {
93
164
  if containsControlCharacters(method) {
@@ -550,6 +550,119 @@ final class SniConnectValidationTests: XCTestCase {
550
550
  ])
551
551
  }
552
552
 
553
+ func testUploadMethodAllowsOnlyPostAndPut() {
554
+ XCTAssertNoThrow(try SniConnectValidation.validateUploadMethod("POST"))
555
+ XCTAssertNoThrow(try SniConnectValidation.validateUploadMethod("PUT"))
556
+ ["GET", "HEAD", "PATCH", "DELETE", "OPTIONS"].forEach { method in
557
+ assertValidationFails {
558
+ try SniConnectValidation.validateUploadMethod(method)
559
+ }
560
+ }
561
+ }
562
+
563
+ func testResolveUploadFileAcceptsSandboxFileByPathAndFileURL() throws {
564
+ let root = try makeUploadRoot()
565
+ defer { try? FileManager.default.removeItem(at: root) }
566
+ let file = root.appendingPathComponent("logs.zip")
567
+ try Data([0x50, 0x4B, 0x03, 0x04, 0xFF]).write(to: file)
568
+
569
+ let byPath = try SniConnectValidation.resolveUploadFile(file.path, allowedRoots: [root])
570
+ XCTAssertEqual(byPath.sizeBytes, 5)
571
+ XCTAssertEqual(
572
+ byPath.url.path,
573
+ file.standardizedFileURL.resolvingSymlinksInPath().path
574
+ )
575
+
576
+ let byURL = try SniConnectValidation.resolveUploadFile(
577
+ file.absoluteString,
578
+ allowedRoots: [root]
579
+ )
580
+ XCTAssertEqual(byURL.url.path, byPath.url.path)
581
+
582
+ let bySingleSlashURL = try SniConnectValidation.resolveUploadFile(
583
+ "file:" + file.path,
584
+ allowedRoots: [root]
585
+ )
586
+ XCTAssertEqual(bySingleSlashURL.url.path, byPath.url.path)
587
+ }
588
+
589
+ func testResolveUploadFileRejectsInvalidOrOutOfSandboxFiles() throws {
590
+ let root = try makeUploadRoot()
591
+ let outside = try makeUploadRoot()
592
+ defer {
593
+ try? FileManager.default.removeItem(at: root)
594
+ try? FileManager.default.removeItem(at: outside)
595
+ }
596
+ let outsideFile = outside.appendingPathComponent("secret.bin")
597
+ try Data([1, 2, 3]).write(to: outsideFile)
598
+ let emptyFile = root.appendingPathComponent("empty.zip")
599
+ try Data().write(to: emptyFile)
600
+ let link = root.appendingPathComponent("link.zip")
601
+ try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outsideFile)
602
+
603
+ [
604
+ "",
605
+ "relative/logs.zip",
606
+ "https://example.com/logs.zip",
607
+ outsideFile.path,
608
+ root.appendingPathComponent("../\(outside.lastPathComponent)/secret.bin").path,
609
+ link.path,
610
+ emptyFile.path,
611
+ root.path,
612
+ root.appendingPathComponent("missing.zip").path,
613
+ root.path + "/logs\n.zip",
614
+ ].forEach { path in
615
+ assertValidationFails {
616
+ _ = try SniConnectValidation.resolveUploadFile(path, allowedRoots: [root])
617
+ }
618
+ }
619
+ }
620
+
621
+ func testResolveUploadFileRejectsFilesOverTheSizeLimit() throws {
622
+ let root = try makeUploadRoot()
623
+ defer { try? FileManager.default.removeItem(at: root) }
624
+ let file = root.appendingPathComponent("big.zip")
625
+ FileManager.default.createFile(atPath: file.path, contents: nil)
626
+ let handle = try FileHandle(forWritingTo: file)
627
+ try handle.truncate(atOffset: UInt64(SniConnectValidation.maxUploadFileBytes + 1))
628
+ try handle.close()
629
+
630
+ assertValidationFails {
631
+ _ = try SniConnectValidation.resolveUploadFile(file.path, allowedRoots: [root])
632
+ }
633
+ }
634
+
635
+ func testReadUploadFileAcceptsExactLimitBinaryData() throws {
636
+ let root = try makeUploadRoot()
637
+ defer { try? FileManager.default.removeItem(at: root) }
638
+ let file = root.appendingPathComponent("exact.zip")
639
+ let expected = Data(repeating: 0xFF, count: SniConnectValidation.maxUploadFileBytes)
640
+ try expected.write(to: file)
641
+ XCTAssertEqual(try SniConnectValidation.readUploadFile(file), expected)
642
+ }
643
+
644
+ func testReadUploadFileRejectsGrowthAndTruncationAfterValidation() throws {
645
+ let root = try makeUploadRoot()
646
+ defer { try? FileManager.default.removeItem(at: root) }
647
+ let file = root.appendingPathComponent("changing.zip")
648
+ try Data([1]).write(to: file)
649
+ let validated = try SniConnectValidation.resolveUploadFile(file.path, allowedRoots: [root])
650
+ let handle = try FileHandle(forWritingTo: file)
651
+ defer { try? handle.close() }
652
+ // A sparse file exercises the consumption bound without a large fixture.
653
+ try handle.truncate(atOffset: UInt64(SniConnectValidation.maxUploadFileBytes * 4))
654
+ XCTAssertThrowsError(try SniConnectValidation.readUploadFile(validated.url))
655
+ try handle.truncate(atOffset: 0)
656
+ XCTAssertThrowsError(try SniConnectValidation.readUploadFile(validated.url))
657
+ }
658
+
659
+ private func makeUploadRoot() throws -> URL {
660
+ let root = FileManager.default.temporaryDirectory
661
+ .appendingPathComponent("sni-upload-\(UUID().uuidString)", isDirectory: true)
662
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
663
+ return root
664
+ }
665
+
553
666
  private func assertValidationFails(_ block: () throws -> Void, file: StaticString = #filePath, line: UInt = #line) {
554
667
  XCTAssertThrowsError(try block(), file: file, line: line)
555
668
  }
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
2
 
3
3
  import { NativeModules, Platform, TurboModuleRegistry } from 'react-native';
4
+
5
+ /**
6
+ * Sends the raw bytes of an app-sandbox file as the request body over the same
7
+ * pinned-IP/SNI transport as `request()`. Set `content-type` in `headers`.
8
+ */
9
+
4
10
  const LINKING_ERROR = `The package '@onekeyfe/react-native-sni-connect' doesn't seem to be linked. Make sure:\n\n` + Platform.select({
5
11
  ios: "- You have run 'pod install'\n",
6
12
  default: ''
@@ -4,6 +4,17 @@ import NativeSniConnect from "./NativeSniConnect.js";
4
4
  export function request(config) {
5
5
  return NativeSniConnect.request(config);
6
6
  }
7
+ export function isUploadFileSupported() {
8
+ return typeof NativeSniConnect.uploadFile === 'function';
9
+ }
10
+ export async function uploadFile(config) {
11
+ if (!isUploadFileSupported()) {
12
+ throw Object.assign(new Error('Native SNI file upload is not supported'), {
13
+ code: 'SNI_UNSUPPORTED'
14
+ });
15
+ }
16
+ return NativeSniConnect.uploadFile(config);
17
+ }
7
18
  export function cancelRequest(requestId) {
8
19
  return NativeSniConnect.cancelRequest(requestId);
9
20
  }
@@ -18,6 +18,20 @@ type NativeSniConnectRequest = SniConnectRequestBase & {
18
18
  method: string;
19
19
  body?: string | null;
20
20
  };
21
+ type NativeSniConnectUploadFileRequest = SniConnectRequestBase & {
22
+ method: string;
23
+ filePath: string;
24
+ };
25
+ export type SniConnectUploadFileMethod = 'POST' | 'PUT';
26
+ /**
27
+ * Sends the raw bytes of an app-sandbox file as the request body over the same
28
+ * pinned-IP/SNI transport as `request()`. Set `content-type` in `headers`.
29
+ */
30
+ export type SniConnectUploadFileRequest = SniConnectRequestBase & {
31
+ method: SniConnectUploadFileMethod;
32
+ /** Absolute path or `file://` URL inside the app sandbox, at most 20 MiB. */
33
+ filePath: string;
34
+ };
21
35
  export type SniConnectBodylessMethod = 'GET' | 'HEAD';
22
36
  export type SniConnectRequiredBodyMethod = 'POST' | 'PUT' | 'PATCH';
23
37
  export type SniConnectOptionalBodyMethod = 'DELETE' | 'OPTIONS';
@@ -53,6 +67,7 @@ export type SniConnectDebugSnapshot = {
53
67
  };
54
68
  export interface Spec extends TurboModule {
55
69
  request(config: NativeSniConnectRequest): Promise<SniConnectResponse>;
70
+ uploadFile(config: NativeSniConnectUploadFileRequest): Promise<SniConnectResponse>;
56
71
  cancelRequest(requestId: string): Promise<{
57
72
  success: boolean;
58
73
  }>;
@@ -1,5 +1,7 @@
1
- import { type SniConnectDebugSnapshot, type SniConnectDebugTarget, type SniConnectRequest, type SniConnectResponse } from './NativeSniConnect';
1
+ import { type SniConnectDebugSnapshot, type SniConnectDebugTarget, type SniConnectRequest, type SniConnectResponse, type SniConnectUploadFileRequest } from './NativeSniConnect';
2
2
  export declare function request(config: SniConnectRequest): Promise<SniConnectResponse>;
3
+ export declare function isUploadFileSupported(): boolean;
4
+ export declare function uploadFile(config: SniConnectUploadFileRequest): Promise<SniConnectResponse>;
3
5
  export declare function cancelRequest(requestId: string): Promise<{
4
6
  success: boolean;
5
7
  }>;
@@ -11,5 +13,5 @@ export declare function clearDNSCache(): Promise<{
11
13
  }>;
12
14
  export declare function getDebugSnapshot(target: SniConnectDebugTarget): Promise<SniConnectDebugSnapshot>;
13
15
  export declare function isProxyActiveForUrl(url: string): Promise<boolean>;
14
- export type { SniConnectBodylessMethod, SniConnectDebugSnapshot, SniConnectDebugTarget, SniConnectMethod, SniConnectOptionalBodyMethod, SniConnectRequest, SniConnectRequiredBodyMethod, SniConnectResponse, } from './NativeSniConnect';
16
+ export type { SniConnectBodylessMethod, SniConnectDebugSnapshot, SniConnectDebugTarget, SniConnectMethod, SniConnectOptionalBodyMethod, SniConnectRequest, SniConnectRequiredBodyMethod, SniConnectResponse, SniConnectUploadFileMethod, SniConnectUploadFileRequest, } from './NativeSniConnect';
15
17
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-sni-connect",
3
- "version": "6.7.0-2",
3
+ "version": "6.7.0-20",
4
4
  "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -28,6 +28,23 @@ type NativeSniConnectRequest = SniConnectRequestBase & {
28
28
  body?: string | null;
29
29
  };
30
30
 
31
+ type NativeSniConnectUploadFileRequest = SniConnectRequestBase & {
32
+ method: string;
33
+ filePath: string;
34
+ };
35
+
36
+ export type SniConnectUploadFileMethod = 'POST' | 'PUT';
37
+
38
+ /**
39
+ * Sends the raw bytes of an app-sandbox file as the request body over the same
40
+ * pinned-IP/SNI transport as `request()`. Set `content-type` in `headers`.
41
+ */
42
+ export type SniConnectUploadFileRequest = SniConnectRequestBase & {
43
+ method: SniConnectUploadFileMethod;
44
+ /** Absolute path or `file://` URL inside the app sandbox, at most 20 MiB. */
45
+ filePath: string;
46
+ };
47
+
31
48
  export type SniConnectBodylessMethod = 'GET' | 'HEAD';
32
49
  export type SniConnectRequiredBodyMethod = 'POST' | 'PUT' | 'PATCH';
33
50
  export type SniConnectOptionalBodyMethod = 'DELETE' | 'OPTIONS';
@@ -74,6 +91,9 @@ export type SniConnectDebugSnapshot = {
74
91
 
75
92
  export interface Spec extends TurboModule {
76
93
  request(config: NativeSniConnectRequest): Promise<SniConnectResponse>;
94
+ uploadFile(
95
+ config: NativeSniConnectUploadFileRequest
96
+ ): Promise<SniConnectResponse>;
77
97
  cancelRequest(requestId: string): Promise<{ success: boolean }>;
78
98
  cancelAllRequests(): Promise<{ success: boolean }>;
79
99
  clearDNSCache(): Promise<{ success: boolean }>;
package/src/index.tsx CHANGED
@@ -3,6 +3,7 @@ import NativeSniConnect, {
3
3
  type SniConnectDebugTarget,
4
4
  type SniConnectRequest,
5
5
  type SniConnectResponse,
6
+ type SniConnectUploadFileRequest,
6
7
  } from './NativeSniConnect';
7
8
 
8
9
  export function request(
@@ -11,6 +12,21 @@ export function request(
11
12
  return NativeSniConnect.request(config);
12
13
  }
13
14
 
15
+ export function isUploadFileSupported(): boolean {
16
+ return typeof NativeSniConnect.uploadFile === 'function';
17
+ }
18
+
19
+ export async function uploadFile(
20
+ config: SniConnectUploadFileRequest
21
+ ): Promise<SniConnectResponse> {
22
+ if (!isUploadFileSupported()) {
23
+ throw Object.assign(new Error('Native SNI file upload is not supported'), {
24
+ code: 'SNI_UNSUPPORTED',
25
+ });
26
+ }
27
+ return NativeSniConnect.uploadFile(config);
28
+ }
29
+
14
30
  export function cancelRequest(
15
31
  requestId: string
16
32
  ): Promise<{ success: boolean }> {
@@ -44,4 +60,6 @@ export type {
44
60
  SniConnectRequest,
45
61
  SniConnectRequiredBodyMethod,
46
62
  SniConnectResponse,
63
+ SniConnectUploadFileMethod,
64
+ SniConnectUploadFileRequest,
47
65
  } from './NativeSniConnect';