@hot-updater/react-native 0.21.4 → 0.21.6

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.
@@ -132,7 +132,6 @@ dependencies {
132
132
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
133
133
  implementation "com.squareup.okhttp3:okhttp:4.12.0"
134
134
  implementation "org.brotli:dec:0.1.2"
135
- implementation "org.apache.commons:commons-compress:1.28.0"
136
135
  }
137
136
 
138
137
  if (isNewArchitectureEnabled()) {
@@ -0,0 +1,387 @@
1
+ package com.hotupdater
2
+
3
+ import java.io.EOFException
4
+ import java.io.IOException
5
+ import java.io.InputStream
6
+
7
+ /**
8
+ * Secure TAR archive input stream for Android API 21+
9
+ * Replaces Apache Commons Compress to avoid java.nio.file dependencies
10
+ */
11
+ class TarArchiveInputStream(
12
+ private val input: InputStream,
13
+ ) : InputStream() {
14
+ private var currentEntry: TarArchiveEntry? = null
15
+ private var currentEntryBytesRead: Long = 0
16
+ private var longName: String? = null
17
+
18
+ companion object {
19
+ private const val TAG = "TarInputStream"
20
+ private const val BLOCK_SIZE = 512
21
+ private const val NAME_OFFSET = 0
22
+ private const val NAME_LENGTH = 100
23
+ private const val MODE_OFFSET = 100
24
+ private const val SIZE_OFFSET = 124
25
+ private const val SIZE_LENGTH = 12
26
+ private const val CHECKSUM_OFFSET = 148
27
+ private const val CHECKSUM_LENGTH = 8
28
+ private const val TYPEFLAG_OFFSET = 156
29
+ private const val LINKNAME_OFFSET = 157
30
+ private const val LINKNAME_LENGTH = 100
31
+ private const val MAGIC_OFFSET = 257
32
+ private const val PREFIX_OFFSET = 345
33
+ private const val PREFIX_LENGTH = 155
34
+
35
+ // Maximum file size: 1GB per file
36
+ private const val MAX_FILE_SIZE = 1_073_741_824L
37
+ }
38
+
39
+ /**
40
+ * Get the next TAR entry
41
+ */
42
+ fun getNextEntry(): TarArchiveEntry? {
43
+ // Skip remaining bytes of current entry
44
+ if (currentEntry != null) {
45
+ val remaining = currentEntry!!.size - currentEntryBytesRead
46
+ if (remaining > 0) {
47
+ skipBytes(remaining)
48
+ }
49
+ skipPadding(currentEntry!!.size)
50
+ }
51
+
52
+ currentEntryBytesRead = 0
53
+
54
+ while (true) {
55
+ val headerBytes = readBlock() ?: return null
56
+
57
+ // Check for end of archive (all zeros)
58
+ if (isAllZeros(headerBytes)) {
59
+ return null
60
+ }
61
+
62
+ // Verify header
63
+ if (!isValidHeader(headerBytes)) {
64
+ throw IOException("Invalid TAR header")
65
+ }
66
+
67
+ if (!verifyChecksum(headerBytes)) {
68
+ throw IOException("TAR header checksum verification failed")
69
+ }
70
+
71
+ // Parse header
72
+ val entry = parseHeader(headerBytes)
73
+
74
+ // Handle GNU long filename extension
75
+ if (entry.typeFlag == 'L') {
76
+ longName = readLongName(entry.size)
77
+ continue
78
+ }
79
+
80
+ // Apply long name if present
81
+ if (longName != null) {
82
+ entry.name = longName!!
83
+ longName = null
84
+ }
85
+
86
+ // Validate entry
87
+ validateEntry(entry)
88
+
89
+ currentEntry = entry
90
+ return entry
91
+ }
92
+ }
93
+
94
+ override fun read(): Int {
95
+ val b = ByteArray(1)
96
+ val n = read(b, 0, 1)
97
+ return if (n <= 0) -1 else b[0].toInt() and 0xFF
98
+ }
99
+
100
+ override fun read(
101
+ b: ByteArray,
102
+ off: Int,
103
+ len: Int,
104
+ ): Int {
105
+ if (currentEntry == null) {
106
+ throw IllegalStateException("No current entry")
107
+ }
108
+
109
+ val remaining = currentEntry!!.size - currentEntryBytesRead
110
+ if (remaining <= 0) {
111
+ return -1
112
+ }
113
+
114
+ val toRead = minOf(len.toLong(), remaining).toInt()
115
+ val bytesRead = input.read(b, off, toRead)
116
+
117
+ if (bytesRead > 0) {
118
+ currentEntryBytesRead += bytesRead
119
+ }
120
+
121
+ return bytesRead
122
+ }
123
+
124
+ override fun close() {
125
+ input.close()
126
+ }
127
+
128
+ /**
129
+ * Read a 512-byte block from input
130
+ */
131
+ private fun readBlock(): ByteArray? {
132
+ val block = ByteArray(BLOCK_SIZE)
133
+ var offset = 0
134
+
135
+ while (offset < BLOCK_SIZE) {
136
+ val n = input.read(block, offset, BLOCK_SIZE - offset)
137
+ if (n < 0) {
138
+ return if (offset == 0) null else throw EOFException("Unexpected end of TAR archive")
139
+ }
140
+ offset += n
141
+ }
142
+
143
+ return block
144
+ }
145
+
146
+ /**
147
+ * Check if block is all zeros
148
+ */
149
+ private fun isAllZeros(block: ByteArray): Boolean = block.all { it == 0.toByte() }
150
+
151
+ /**
152
+ * Verify TAR header has valid magic number
153
+ */
154
+ private fun isValidHeader(header: ByteArray): Boolean {
155
+ // Check for "ustar" magic (may have \0 or space after)
156
+ val magic = String(header, MAGIC_OFFSET, 5, Charsets.US_ASCII)
157
+ return magic == "ustar"
158
+ }
159
+
160
+ /**
161
+ * Verify header checksum
162
+ */
163
+ private fun verifyChecksum(header: ByteArray): Boolean {
164
+ val storedChecksum = parseOctal(header, CHECKSUM_OFFSET, CHECKSUM_LENGTH).toInt()
165
+
166
+ // Calculate checksums (both signed and unsigned for compatibility)
167
+ var unsignedSum = 0
168
+ var signedSum = 0
169
+
170
+ for (i in 0 until BLOCK_SIZE) {
171
+ val value =
172
+ if (i in CHECKSUM_OFFSET until CHECKSUM_OFFSET + CHECKSUM_LENGTH) {
173
+ 32 // Space character
174
+ } else {
175
+ header[i].toInt()
176
+ }
177
+
178
+ unsignedSum += value and 0xFF
179
+ signedSum += value.toByte().toInt()
180
+ }
181
+
182
+ return storedChecksum == unsignedSum || storedChecksum == signedSum
183
+ }
184
+
185
+ /**
186
+ * Parse TAR header into TarArchiveEntry
187
+ */
188
+ private fun parseHeader(header: ByteArray): TarArchiveEntry {
189
+ val name = parseString(header, NAME_OFFSET, NAME_LENGTH)
190
+ val mode = parseOctal(header, MODE_OFFSET, 8).toInt()
191
+ val size = parseNumeric(header, SIZE_OFFSET, SIZE_LENGTH)
192
+ val typeFlag = header[TYPEFLAG_OFFSET].toInt().toChar()
193
+ val linkName = parseString(header, LINKNAME_OFFSET, LINKNAME_LENGTH)
194
+ val prefix = parseString(header, PREFIX_OFFSET, PREFIX_LENGTH)
195
+
196
+ // Combine prefix and name
197
+ val fullName = if (prefix.isNotEmpty()) "$prefix/$name" else name
198
+
199
+ return TarArchiveEntry(
200
+ name = fullName,
201
+ mode = mode,
202
+ size = size,
203
+ typeFlag = typeFlag,
204
+ linkName = linkName,
205
+ )
206
+ }
207
+
208
+ /**
209
+ * Parse string field from header
210
+ */
211
+ private fun parseString(
212
+ bytes: ByteArray,
213
+ offset: Int,
214
+ length: Int,
215
+ ): String {
216
+ var end = offset
217
+ while (end < offset + length && bytes[end] != 0.toByte()) {
218
+ end++
219
+ }
220
+ return String(bytes, offset, end - offset, Charsets.UTF_8).trim()
221
+ }
222
+
223
+ /**
224
+ * Parse octal number from header field
225
+ */
226
+ private fun parseOctal(
227
+ bytes: ByteArray,
228
+ offset: Int,
229
+ length: Int,
230
+ ): Long {
231
+ var result = 0L
232
+ var i = offset
233
+ val end = offset + length
234
+
235
+ // Skip leading spaces
236
+ while (i < end && bytes[i] == ' '.code.toByte()) i++
237
+
238
+ // Parse octal digits
239
+ while (i < end) {
240
+ val b = bytes[i]
241
+ if (b == 0.toByte() || b == ' '.code.toByte()) break
242
+ if (b < '0'.code.toByte() || b > '7'.code.toByte()) {
243
+ throw IOException("Invalid octal digit: ${b.toInt()}")
244
+ }
245
+ result = result * 8 + (b - '0'.code.toByte())
246
+ i++
247
+ }
248
+
249
+ return result
250
+ }
251
+
252
+ /**
253
+ * Parse numeric field (supports both octal and base-256 encoding)
254
+ */
255
+ private fun parseNumeric(
256
+ bytes: ByteArray,
257
+ offset: Int,
258
+ length: Int,
259
+ ): Long {
260
+ // Check for base-256 encoding (high bit set)
261
+ if ((bytes[offset].toInt() and 0x80) != 0) {
262
+ return parseBase256(bytes, offset, length)
263
+ }
264
+ return parseOctal(bytes, offset, length)
265
+ }
266
+
267
+ /**
268
+ * Parse base-256 encoded number (for files > 8GB)
269
+ */
270
+ private fun parseBase256(
271
+ bytes: ByteArray,
272
+ offset: Int,
273
+ length: Int,
274
+ ): Long {
275
+ var result = 0L
276
+
277
+ // Skip first byte (marker) and read big-endian
278
+ for (i in 1 until length) {
279
+ result = (result shl 8) or (bytes[offset + i].toInt() and 0xFF).toLong()
280
+ }
281
+
282
+ return result
283
+ }
284
+
285
+ /**
286
+ * Read GNU long filename extension
287
+ */
288
+ private fun readLongName(size: Long): String {
289
+ val nameBytes = ByteArray(size.toInt())
290
+ var offset = 0
291
+
292
+ while (offset < size) {
293
+ val n = input.read(nameBytes, offset, size.toInt() - offset)
294
+ if (n < 0) throw EOFException("Unexpected end reading long name")
295
+ offset += n
296
+ }
297
+
298
+ skipPadding(size)
299
+
300
+ // Remove trailing NUL
301
+ val nameLength =
302
+ nameBytes
303
+ .indexOfFirst { it == 0.toByte() }
304
+ .takeIf { it >= 0 } ?: nameBytes.size
305
+
306
+ return String(nameBytes, 0, nameLength, Charsets.UTF_8)
307
+ }
308
+
309
+ /**
310
+ * Skip padding to 512-byte boundary
311
+ */
312
+ private fun skipPadding(size: Long) {
313
+ val remainder = size % BLOCK_SIZE
314
+ if (remainder != 0L) {
315
+ skipBytes(BLOCK_SIZE - remainder)
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Skip specified number of bytes
321
+ */
322
+ private fun skipBytes(n: Long) {
323
+ var remaining = n
324
+ val buffer = ByteArray(8192)
325
+
326
+ while (remaining > 0) {
327
+ val toSkip = minOf(buffer.size.toLong(), remaining).toInt()
328
+ val skipped = input.read(buffer, 0, toSkip)
329
+ if (skipped < 0) throw EOFException("Unexpected end of stream")
330
+ remaining -= skipped
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Validate entry for security issues
336
+ */
337
+ private fun validateEntry(entry: TarArchiveEntry) {
338
+ // Check for negative or excessive file size
339
+ if (entry.size < 0) {
340
+ throw SecurityException("Negative file size: ${entry.size}")
341
+ }
342
+
343
+ if (entry.size > MAX_FILE_SIZE) {
344
+ throw SecurityException("File size ${entry.size} exceeds maximum $MAX_FILE_SIZE")
345
+ }
346
+
347
+ // Check for absolute paths
348
+ if (entry.name.startsWith("/")) {
349
+ throw SecurityException("Absolute path not allowed: ${entry.name}")
350
+ }
351
+
352
+ // Check for path traversal
353
+ val normalized = entry.name.replace('\\', '/')
354
+ if (normalized.contains("../") ||
355
+ normalized.contains("/..") ||
356
+ normalized == ".." ||
357
+ normalized.startsWith("../")
358
+ ) {
359
+ throw SecurityException("Path traversal detected: ${entry.name}")
360
+ }
361
+
362
+ // Check for null bytes in filename
363
+ if (entry.name.contains('\u0000')) {
364
+ throw SecurityException("Null byte in filename: ${entry.name}")
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * TAR archive entry
371
+ */
372
+ data class TarArchiveEntry(
373
+ var name: String,
374
+ val mode: Int,
375
+ val size: Long,
376
+ val typeFlag: Char,
377
+ val linkName: String,
378
+ ) {
379
+ val isDirectory: Boolean
380
+ get() = typeFlag == '5' || name.endsWith('/')
381
+
382
+ val isFile: Boolean
383
+ get() = typeFlag == '0' || typeFlag == '\u0000'
384
+
385
+ val isSymbolicLink: Boolean
386
+ get() = typeFlag == '2'
387
+ }
@@ -1,8 +1,7 @@
1
1
  package com.hotupdater
2
2
 
3
3
  import android.util.Log
4
- import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
5
- import org.apache.commons.compress.compressors.brotli.BrotliCompressorInputStream
4
+ import org.brotli.dec.BrotliInputStream
6
5
  import java.io.BufferedInputStream
7
6
  import java.io.File
8
7
  import java.io.FileInputStream
@@ -10,6 +9,7 @@ import java.io.FileOutputStream
10
9
 
11
10
  /**
12
11
  * Strategy for handling TAR+Brotli compressed files
12
+ * Uses native Brotli decoder and custom TAR parser
13
13
  */
14
14
  class TarBrDecompressionStrategy : DecompressionStrategy {
15
15
  companion object {
@@ -55,16 +55,16 @@ class TarBrDecompressionStrategy : DecompressionStrategy {
55
55
 
56
56
  FileInputStream(filePath).use { fileInputStream ->
57
57
  BufferedInputStream(fileInputStream).use { bufferedInputStream ->
58
- BrotliCompressorInputStream(bufferedInputStream).use { brotliInputStream ->
58
+ BrotliInputStream(bufferedInputStream).use { brotliInputStream ->
59
59
  TarArchiveInputStream(brotliInputStream).use { tarInputStream ->
60
- var entry = tarInputStream.nextEntry
60
+ var entry = tarInputStream.getNextEntry()
61
61
 
62
62
  while (entry != null) {
63
63
  val file = File(destinationPath, entry.name)
64
64
 
65
65
  if (!file.canonicalPath.startsWith(destinationDir.canonicalPath)) {
66
66
  Log.w(TAG, "Skipping potentially malicious tar entry: ${entry.name}")
67
- entry = tarInputStream.nextEntry
67
+ entry = tarInputStream.getNextEntry()
68
68
  continue
69
69
  }
70
70
 
@@ -87,7 +87,7 @@ class TarBrDecompressionStrategy : DecompressionStrategy {
87
87
  val progress = processedBytes.toDouble() / (totalSize * 2.0)
88
88
  progressCallback.invoke(progress.coerceIn(0.0, 1.0))
89
89
 
90
- entry = tarInputStream.nextEntry
90
+ entry = tarInputStream.getNextEntry()
91
91
  }
92
92
  }
93
93
  }
@@ -1,15 +1,15 @@
1
1
  package com.hotupdater
2
2
 
3
3
  import android.util.Log
4
- import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
5
- import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
6
4
  import java.io.BufferedInputStream
7
5
  import java.io.File
8
6
  import java.io.FileInputStream
9
7
  import java.io.FileOutputStream
8
+ import java.util.zip.GZIPInputStream
10
9
 
11
10
  /**
12
11
  * Strategy for handling TAR+GZIP compressed files
12
+ * Uses native GZIP decoder and custom TAR parser
13
13
  */
14
14
  class TarGzDecompressionStrategy : DecompressionStrategy {
15
15
  companion object {
@@ -68,16 +68,16 @@ class TarGzDecompressionStrategy : DecompressionStrategy {
68
68
 
69
69
  FileInputStream(filePath).use { fileInputStream ->
70
70
  BufferedInputStream(fileInputStream).use { bufferedInputStream ->
71
- GzipCompressorInputStream(bufferedInputStream).use { gzipInputStream ->
71
+ GZIPInputStream(bufferedInputStream).use { gzipInputStream ->
72
72
  TarArchiveInputStream(gzipInputStream).use { tarInputStream ->
73
- var entry = tarInputStream.nextEntry
73
+ var entry = tarInputStream.getNextEntry()
74
74
 
75
75
  while (entry != null) {
76
76
  val file = File(destinationPath, entry.name)
77
77
 
78
78
  if (!file.canonicalPath.startsWith(destinationDir.canonicalPath)) {
79
79
  Log.w(TAG, "Skipping potentially malicious tar entry: ${entry.name}")
80
- entry = tarInputStream.nextEntry
80
+ entry = tarInputStream.getNextEntry()
81
81
  continue
82
82
  }
83
83
 
@@ -100,7 +100,7 @@ class TarGzDecompressionStrategy : DecompressionStrategy {
100
100
  val progress = processedBytes.toDouble() / (totalSize * 2.0)
101
101
  progressCallback.invoke(progress.coerceIn(0.0, 1.0))
102
102
 
103
- entry = tarInputStream.nextEntry
103
+ entry = tarInputStream.getNextEntry()
104
104
  }
105
105
  }
106
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/react-native",
3
- "version": "0.21.4",
3
+ "version": "0.21.6",
4
4
  "description": "React Native OTA solution for self-hosted",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -119,13 +119,13 @@
119
119
  "react-native": "0.79.1",
120
120
  "react-native-builder-bob": "^0.40.10",
121
121
  "typescript": "^5.8.3",
122
- "hot-updater": "0.21.4"
122
+ "hot-updater": "0.21.6"
123
123
  },
124
124
  "dependencies": {
125
125
  "use-sync-external-store": "1.5.0",
126
- "@hot-updater/core": "0.21.4",
127
- "@hot-updater/js": "0.21.4",
128
- "@hot-updater/plugin-core": "0.21.4"
126
+ "@hot-updater/core": "0.21.6",
127
+ "@hot-updater/plugin-core": "0.21.6",
128
+ "@hot-updater/js": "0.21.6"
129
129
  },
130
130
  "scripts": {
131
131
  "build": "bob build && tsc -p plugin/tsconfig.json",