@hot-updater/react-native 1.0.0-rc.14 → 1.0.0-rc.15

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.
Files changed (181) hide show
  1. package/android/proguard-rules.pro +2 -2
  2. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +286 -0
  3. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +650 -636
  4. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +3 -0
  5. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +0 -13
  6. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +10 -14
  7. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +19 -0
  8. package/android/src/main/java/com/hotupdater/TarArchiveInputStream.kt +214 -375
  9. package/android/src/main/java/com/hotupdater/TarBrArchiveExtractor.kt +119 -0
  10. package/android/src/newarch/HotUpdater.kt +7 -9
  11. package/android/src/newarch/HotUpdaterModule.kt +40 -20
  12. package/android/src/oldarch/HotUpdater.kt +7 -9
  13. package/android/src/oldarch/HotUpdaterModule.kt +40 -20
  14. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +1126 -74
  15. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +4 -5
  16. package/android/src/test/java/com/hotupdater/TarBrArchiveExtractorTest.kt +206 -0
  17. package/ios/HotUpdater/Internal/BrotliFileDecompressor.swift +167 -0
  18. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +196 -0
  19. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +773 -1016
  20. package/ios/HotUpdater/Internal/BundleMetadata.swift +5 -0
  21. package/ios/HotUpdater/Internal/FileUtilities.swift +64 -0
  22. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +0 -1
  23. package/ios/HotUpdater/Internal/HotUpdater.mm +5 -27
  24. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +47 -31
  25. package/ios/HotUpdater/Internal/NotificationExtension.swift +0 -2
  26. package/ios/HotUpdater/Internal/TarArchiveExtractor.swift +182 -311
  27. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +13 -72
  28. package/lib/commonjs/checkForUpdate.js +7 -10
  29. package/lib/commonjs/checkForUpdate.js.map +1 -1
  30. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +9 -4
  31. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +1 -1
  32. package/lib/commonjs/error.js +4 -13
  33. package/lib/commonjs/error.js.map +1 -1
  34. package/lib/commonjs/fetchJSON.js +9 -2
  35. package/lib/commonjs/fetchJSON.js.map +1 -1
  36. package/lib/commonjs/httpClient.js +44 -27
  37. package/lib/commonjs/httpClient.js.map +1 -1
  38. package/lib/commonjs/httpClient.spec.js +57 -5
  39. package/lib/commonjs/httpClient.spec.js.map +1 -1
  40. package/lib/commonjs/index.js +2 -6
  41. package/lib/commonjs/index.js.map +1 -1
  42. package/lib/commonjs/native.js +7 -8
  43. package/lib/commonjs/native.js.map +1 -1
  44. package/lib/commonjs/native.spec.js +21 -16
  45. package/lib/commonjs/native.spec.js.map +1 -1
  46. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js +7 -0
  47. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js.map +1 -1
  48. package/lib/commonjs/notifyAppReadyInsights.js +4 -1
  49. package/lib/commonjs/notifyAppReadyInsights.js.map +1 -1
  50. package/lib/commonjs/notifyAppReadyInsights.spec.js +59 -0
  51. package/lib/commonjs/notifyAppReadyInsights.spec.js.map +1 -1
  52. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js +7 -0
  53. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js.map +1 -1
  54. package/lib/commonjs/sdkVersion.js +1 -1
  55. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  56. package/lib/commonjs/store.js +3 -10
  57. package/lib/commonjs/store.js.map +1 -1
  58. package/lib/commonjs/store.notification.spec.js +12 -4
  59. package/lib/commonjs/store.notification.spec.js.map +1 -1
  60. package/lib/commonjs/store.spec.js +2 -136
  61. package/lib/commonjs/store.spec.js.map +1 -1
  62. package/lib/commonjs/wrap.js +1 -3
  63. package/lib/commonjs/wrap.js.map +1 -1
  64. package/lib/commonjs/wrap.spec.js +7 -0
  65. package/lib/commonjs/wrap.spec.js.map +1 -1
  66. package/lib/module/checkForUpdate.js +7 -10
  67. package/lib/module/checkForUpdate.js.map +1 -1
  68. package/lib/module/checkForUpdate.releaseCatalog.spec.js +9 -4
  69. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +1 -1
  70. package/lib/module/error.js +4 -13
  71. package/lib/module/error.js.map +1 -1
  72. package/lib/module/fetchJSON.js +7 -1
  73. package/lib/module/fetchJSON.js.map +1 -1
  74. package/lib/module/httpClient.js +46 -29
  75. package/lib/module/httpClient.js.map +1 -1
  76. package/lib/module/httpClient.spec.js +57 -5
  77. package/lib/module/httpClient.spec.js.map +1 -1
  78. package/lib/module/index.js +2 -6
  79. package/lib/module/index.js.map +1 -1
  80. package/lib/module/native.js +7 -8
  81. package/lib/module/native.js.map +1 -1
  82. package/lib/module/native.spec.js +21 -16
  83. package/lib/module/native.spec.js.map +1 -1
  84. package/lib/module/notifyAppReadyInsights.boundary.spec.js +7 -0
  85. package/lib/module/notifyAppReadyInsights.boundary.spec.js.map +1 -1
  86. package/lib/module/notifyAppReadyInsights.js +5 -2
  87. package/lib/module/notifyAppReadyInsights.js.map +1 -1
  88. package/lib/module/notifyAppReadyInsights.spec.js +59 -0
  89. package/lib/module/notifyAppReadyInsights.spec.js.map +1 -1
  90. package/lib/module/notifyAppReadyInsights.transport.spec.js +7 -0
  91. package/lib/module/notifyAppReadyInsights.transport.spec.js.map +1 -1
  92. package/lib/module/sdkVersion.js +1 -1
  93. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  94. package/lib/module/store.js +3 -10
  95. package/lib/module/store.js.map +1 -1
  96. package/lib/module/store.notification.spec.js +12 -4
  97. package/lib/module/store.notification.spec.js.map +1 -1
  98. package/lib/module/store.spec.js +2 -136
  99. package/lib/module/store.spec.js.map +1 -1
  100. package/lib/module/wrap.js +1 -3
  101. package/lib/module/wrap.js.map +1 -1
  102. package/lib/module/wrap.spec.js +7 -0
  103. package/lib/module/wrap.spec.js.map +1 -1
  104. package/lib/typescript/commonjs/checkForUpdate.d.ts +1 -2
  105. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/error.d.ts +4 -13
  107. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/fetchJSON.d.ts +4 -0
  109. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/index.d.ts +0 -2
  112. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/native.d.ts +1 -6
  114. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts +1 -1
  116. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  118. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +8 -35
  119. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/store.d.ts +1 -3
  121. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  123. package/lib/typescript/commonjs/wrap.types.d.ts +0 -2
  124. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -1
  125. package/lib/typescript/module/checkForUpdate.d.ts +1 -2
  126. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  127. package/lib/typescript/module/error.d.ts +4 -13
  128. package/lib/typescript/module/error.d.ts.map +1 -1
  129. package/lib/typescript/module/fetchJSON.d.ts +4 -0
  130. package/lib/typescript/module/fetchJSON.d.ts.map +1 -1
  131. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  132. package/lib/typescript/module/index.d.ts +0 -2
  133. package/lib/typescript/module/index.d.ts.map +1 -1
  134. package/lib/typescript/module/native.d.ts +1 -6
  135. package/lib/typescript/module/native.d.ts.map +1 -1
  136. package/lib/typescript/module/notifyAppReadyInsights.d.ts +1 -1
  137. package/lib/typescript/module/notifyAppReadyInsights.d.ts.map +1 -1
  138. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  139. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +8 -35
  140. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  141. package/lib/typescript/module/store.d.ts +1 -3
  142. package/lib/typescript/module/store.d.ts.map +1 -1
  143. package/lib/typescript/module/wrap.d.ts.map +1 -1
  144. package/lib/typescript/module/wrap.types.d.ts +0 -2
  145. package/lib/typescript/module/wrap.types.d.ts.map +1 -1
  146. package/package.json +3 -3
  147. package/src/checkForUpdate.releaseCatalog.spec.ts +10 -4
  148. package/src/checkForUpdate.ts +5 -11
  149. package/src/error.ts +4 -14
  150. package/src/fetchJSON.ts +12 -1
  151. package/src/httpClient.spec.ts +70 -5
  152. package/src/httpClient.ts +67 -53
  153. package/src/index.ts +1 -5
  154. package/src/native.spec.ts +23 -16
  155. package/src/native.ts +12 -24
  156. package/src/notifyAppReadyInsights.boundary.spec.ts +8 -0
  157. package/src/notifyAppReadyInsights.spec.ts +60 -0
  158. package/src/notifyAppReadyInsights.transport.spec.ts +8 -0
  159. package/src/notifyAppReadyInsights.ts +13 -1
  160. package/src/sdkVersion.ts +1 -1
  161. package/src/specs/NativeHotUpdater.ts +8 -36
  162. package/src/store.notification.spec.ts +13 -3
  163. package/src/store.spec.ts +0 -157
  164. package/src/store.ts +2 -23
  165. package/src/wrap.spec.ts +8 -0
  166. package/src/wrap.tsx +0 -2
  167. package/src/wrap.types.ts +0 -2
  168. package/android/src/main/java/com/hotupdater/DecompressService.kt +0 -89
  169. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +0 -26
  170. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +0 -117
  171. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +0 -117
  172. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +0 -192
  173. package/android/src/test/java/com/hotupdater/TarArchiveInputStreamTest.kt +0 -68
  174. package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +0 -178
  175. package/ios/HotUpdater/Internal/DecompressService.swift +0 -112
  176. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +0 -22
  177. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +0 -370
  178. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +0 -39
  179. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +0 -59
  180. package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +0 -482
  181. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +0 -56
@@ -216,11 +216,10 @@ class HotUpdaterImplTest {
216
216
 
217
217
  override suspend fun updateBundle(
218
218
  bundleId: String,
219
- fileUrl: String?,
220
- fileHash: String?,
221
- manifestUrl: String?,
222
- manifestFileHash: String?,
223
- changedAssets: Map<String, ChangedAssetDescriptor>?,
219
+ manifestUrl: String,
220
+ manifestFileHash: String,
221
+ assets: Map<String, ChangedAssetDescriptor>,
222
+ archiveUrl: String?,
224
223
  progressCallback: (UpdateProgressPayload) -> Unit,
225
224
  ) = Unit
226
225
 
@@ -0,0 +1,206 @@
1
+ package com.hotupdater
2
+
3
+ import org.junit.Assert.assertArrayEquals
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertThrows
6
+ import org.junit.Rule
7
+ import org.junit.Test
8
+ import org.junit.rules.TemporaryFolder
9
+ import java.io.ByteArrayInputStream
10
+ import java.io.ByteArrayOutputStream
11
+ import java.io.File
12
+ import java.io.IOException
13
+ import java.util.Base64
14
+
15
+ class TarBrArchiveExtractorTest {
16
+ @get:Rule
17
+ val temporaryFolder = TemporaryFolder()
18
+
19
+ @Test
20
+ fun `extracts exact regular file set`() {
21
+ val destination = extract(VALID, 3072, mapOf("assets/image.png" to 5, "index.android.bundle" to 6))
22
+
23
+ assertArrayEquals("image".toByteArray(), File(destination, "assets/image.png").readBytes())
24
+ assertArrayEquals("bundle".toByteArray(), File(destination, "index.android.bundle").readBytes())
25
+ }
26
+
27
+ @Test
28
+ fun `supports a POSIX PAX long path`() {
29
+ val path = "assets/${"x".repeat(120)}.bin"
30
+ val destination = extract(PAX_LONG_PATH, 3072, mapOf(path to 4))
31
+
32
+ assertArrayEquals("long".toByteArray(), File(destination, path).readBytes())
33
+ }
34
+
35
+ @Test
36
+ fun `rejects duplicate unsafe linked and malformed archives`() {
37
+ val cases =
38
+ listOf(
39
+ Triple(DUPLICATE, 3072L, mapOf("index.android.bundle" to 6L)),
40
+ Triple(TRAVERSAL, 2048L, mapOf("../escape" to 3L)),
41
+ Triple(SYMLINK, 1536L, mapOf("index.android.bundle" to 0L)),
42
+ Triple(SINGLE_END_BLOCK, 1536L, mapOf("index.android.bundle" to 6L)),
43
+ Triple(TRAILING_NON_ZERO, 2560L, mapOf("index.android.bundle" to 6L)),
44
+ )
45
+
46
+ cases.forEachIndexed { index, (fixture, tarSize, files) ->
47
+ val archive = temporaryFolder.newFile("invalid-$index.tar.br").apply { writeBytes(decode(fixture)) }
48
+ val destination = File(temporaryFolder.root, "invalid-$index")
49
+
50
+ assertThrows(Exception::class.java) {
51
+ TarBrArchiveExtractor.extract(archive, destination, tarSize, files)
52
+ }
53
+ check(!destination.exists())
54
+ }
55
+ }
56
+
57
+ @Test
58
+ fun `rejects dangling consecutive duplicate and unsupported extension metadata`() {
59
+ val pathRecord = paxRecord("path", "index.android.bundle")
60
+ val malformedShortRecord = "3 \n".toByteArray()
61
+ val cases =
62
+ listOf(
63
+ tarArchive(tarEntry("PaxHeader", 'x', pathRecord)),
64
+ tarArchive(
65
+ tarEntry("PaxHeader", 'x', pathRecord),
66
+ tarEntry("PaxHeader", 'x', pathRecord),
67
+ ),
68
+ tarArchive(
69
+ tarEntry("PaxHeader", 'x', pathRecord + paxRecord("path", "other")),
70
+ regularEntry("ignored", "bundle"),
71
+ ),
72
+ tarArchive(
73
+ tarEntry("PaxHeader", 'x', malformedShortRecord),
74
+ regularEntry("ignored", "bundle"),
75
+ ),
76
+ tarArchive(
77
+ tarEntry("PaxHeader", 'x', paxRecord("linkpath", "target")),
78
+ regularEntry("index.android.bundle", "bundle"),
79
+ ),
80
+ tarArchive(tarEntry("LongLink", 'L', "name\u0000".toByteArray())),
81
+ )
82
+
83
+ cases.forEach { bytes ->
84
+ assertThrows(IOException::class.java) { consumeTar(bytes) }
85
+ }
86
+ }
87
+
88
+ @Test
89
+ fun `preserves exact header paths and applies PAX size`() {
90
+ val spacedPathArchive = tarArchive(regularEntry("index.android.bundle ", "bundle"))
91
+ TarArchiveInputStream(ByteArrayInputStream(spacedPathArchive)).use { input ->
92
+ assertEquals("index.android.bundle ", input.getNextEntry()?.name)
93
+ }
94
+
95
+ val payload = "data".toByteArray()
96
+ val paxSizeArchive =
97
+ tarArchive(
98
+ tarEntry("PaxHeader", 'x', paxRecord("size", payload.size.toString())),
99
+ tarEntry("index.android.bundle", '0', payload, declaredSize = 1),
100
+ )
101
+ TarArchiveInputStream(ByteArrayInputStream(paxSizeArchive)).use { input ->
102
+ val entry = input.getNextEntry()
103
+ assertEquals(payload.size.toLong(), entry?.size)
104
+ assertArrayEquals(payload, input.readBytes())
105
+ assertEquals(null, input.getNextEntry())
106
+ }
107
+ }
108
+
109
+ private fun extract(
110
+ fixture: String,
111
+ tarSize: Long,
112
+ files: Map<String, Long>,
113
+ ): File {
114
+ val archive = temporaryFolder.newFile("fixture-${System.nanoTime()}.tar.br").apply { writeBytes(decode(fixture)) }
115
+ val destination = File(temporaryFolder.root, "extracted-${System.nanoTime()}")
116
+ TarBrArchiveExtractor.extract(archive, destination, tarSize, files)
117
+ return destination
118
+ }
119
+
120
+ private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)
121
+
122
+ private fun consumeTar(bytes: ByteArray) {
123
+ TarArchiveInputStream(ByteArrayInputStream(bytes)).use { input ->
124
+ while (input.getNextEntry() != null) input.readBytes()
125
+ }
126
+ }
127
+
128
+ private fun paxRecord(
129
+ key: String,
130
+ value: String,
131
+ ): ByteArray {
132
+ var length = key.toByteArray().size + value.toByteArray().size + 3
133
+ while (true) {
134
+ val record = "$length $key=$value\n".toByteArray()
135
+ if (record.size == length) return record
136
+ length = record.size
137
+ }
138
+ }
139
+
140
+ private fun regularEntry(
141
+ name: String,
142
+ content: String,
143
+ ): ByteArray = tarEntry(name, '0', content.toByteArray())
144
+
145
+ private fun tarEntry(
146
+ name: String,
147
+ type: Char,
148
+ payload: ByteArray,
149
+ declaredSize: Int = payload.size,
150
+ ): ByteArray {
151
+ val header = ByteArray(512)
152
+ name.toByteArray().copyInto(header, endIndex = name.toByteArray().size.coerceAtMost(100))
153
+ writeOctal(header, 100, 8, 420)
154
+ writeOctal(header, 108, 8, 0)
155
+ writeOctal(header, 116, 8, 0)
156
+ writeOctal(header, 124, 12, declaredSize)
157
+ writeOctal(header, 136, 12, 0)
158
+ for (index in 148 until 156) header[index] = ' '.code.toByte()
159
+ header[156] = type.code.toByte()
160
+ "ustar".toByteArray().copyInto(header, 257)
161
+ writeOctal(header, 148, 8, header.sumOf { it.toInt() and 0xff })
162
+
163
+ return ByteArrayOutputStream()
164
+ .apply {
165
+ write(header)
166
+ write(payload)
167
+ write(ByteArray((512 - payload.size % 512) % 512))
168
+ }.toByteArray()
169
+ }
170
+
171
+ private fun tarArchive(vararg entries: ByteArray): ByteArray =
172
+ ByteArrayOutputStream()
173
+ .apply {
174
+ entries.forEach(::write)
175
+ write(ByteArray(1024))
176
+ }.toByteArray()
177
+
178
+ private fun writeOctal(
179
+ target: ByteArray,
180
+ offset: Int,
181
+ length: Int,
182
+ value: Int,
183
+ ) {
184
+ val digits = value.toString(8).padStart(length - 2, '0')
185
+ digits.toByteArray().copyInto(target, offset)
186
+ target[offset + length - 2] = 0
187
+ target[offset + length - 1] = ' '.code.toByte()
188
+ }
189
+
190
+ companion object {
191
+ const val VALID =
192
+ "G/8LYI7UVnlCO6lhAMTqIo5tMQLiY7NptuP57iYyogHNeF9BlHNiXHeDMLCAJL3h9im3UyYnSs3aLbJIIBsjml0YwV1+Hd11htDfIA8GgPKHFrFNtD7SOfZo/h9mbgDYuSAJIPmy9Z2b"
193
+ private const val DUPLICATE =
194
+ "G/8L4I/UVnlCO6lh6sQAIm5ssV7ksdnUyXHelkgImbdLFCl08DwgyVOGdSmhfs8LgniesZ82/bHw7qIYoXwdSTsMVSkOBkDMM0vqHj11Ew=="
195
+ private const val TRAVERSAL =
196
+ "G/8H+AfKbRfTdvBDW1YMVZ0c3w7fLhJCyAFHCIm3xTUACIRyzK7qNZRgrBkUGlOQ+PtOBlH/uDAUZ9s9Dw=="
197
+ private const val SYMLINK =
198
+ "G/8FoI7UYs0ZrwQiHgYxvWizaUNMJ8cnP4h9lyiDwKKELcCA8pSZdZEoyN91S9BvNObx0n8IcrtxYn2sUe6QrffCuHk8LCgA"
199
+ private const val SINGLE_END_BLOCK =
200
+ "G/8F4I/UVnlCO6lh6sQAIm5ssV7ksdnUyfH9WyIhZN4uUaQSeB6Q5DGGvrXYkguCOs/YT5v+GLy7ECOUryNph6EqxcEAinlmEw=="
201
+ private const val TRAILING_NON_ZERO =
202
+ "G/8J4Adqc2ds55CPYbC2qk6O87ZEQsi0tUsUKUXN80TyGEPfWmyJBUGcZ6y72vQfJjUzyChogu7Xu5ODMLcUB0MBYu7ZOLkF"
203
+ private const val PAX_LONG_PATH =
204
+ "G/8LYAbCtn2mBfjh0GTohqrNYvNuZjueb2Ij3vmAJjz8C6jLIEtYgqi7gAMNAII62ZkiZILxgnbd1QSM8usRpO6ZyCgYZiz8dTRcx0YB/gG5MBRqZPyVyrk8J47iYenP+zXoZcGa8QLgayISZoflgdi14Q=="
205
+ }
206
+ }
@@ -0,0 +1,167 @@
1
+ import Compression
2
+ import Foundation
3
+
4
+ enum BrotliFileDecompressor {
5
+ private static let bufferSize = 64 * 1024
6
+
7
+ static func decompress(
8
+ from sourcePath: String,
9
+ to outputPath: String,
10
+ expectedOutputByteSize: Int64? = nil
11
+ ) throws {
12
+ if let expectedOutputByteSize, expectedOutputByteSize < 0 {
13
+ throw error(6, "Invalid expected Brotli output size")
14
+ }
15
+ let fileManager = FileManager.default
16
+ let parentDirectory = (outputPath as NSString).deletingLastPathComponent
17
+ try fileManager.createDirectory(
18
+ atPath: parentDirectory,
19
+ withIntermediateDirectories: true
20
+ )
21
+ if fileManager.fileExists(atPath: outputPath) {
22
+ try fileManager.removeItem(atPath: outputPath)
23
+ }
24
+ guard fileManager.createFile(atPath: outputPath, contents: nil) else {
25
+ throw error(1, "Failed to create Brotli output file")
26
+ }
27
+
28
+ let input = try FileHandle(forReadingFrom: URL(fileURLWithPath: sourcePath))
29
+ let output = try FileHandle(forWritingTo: URL(fileURLWithPath: outputPath))
30
+ defer {
31
+ try? input.close()
32
+ try? output.close()
33
+ }
34
+
35
+ var stream = compression_stream(
36
+ dst_ptr: UnsafeMutablePointer<UInt8>(bitPattern: 1)!,
37
+ dst_size: 0,
38
+ src_ptr: UnsafePointer<UInt8>(bitPattern: 1)!,
39
+ src_size: 0,
40
+ state: nil
41
+ )
42
+ guard compression_stream_init(
43
+ &stream,
44
+ COMPRESSION_STREAM_DECODE,
45
+ COMPRESSION_BROTLI
46
+ ) == COMPRESSION_STATUS_OK else {
47
+ throw error(2, "Failed to initialize Brotli decompression")
48
+ }
49
+ defer { compression_stream_destroy(&stream) }
50
+
51
+ let outputBuffer = UnsafeMutablePointer<UInt8>.allocate(
52
+ capacity: bufferSize
53
+ )
54
+ defer { outputBuffer.deallocate() }
55
+
56
+ var reachedEnd = false
57
+ var totalOutputByteSize: UInt64 = 0
58
+ let maximumOutputByteSize = expectedOutputByteSize.map(UInt64.init)
59
+ while !reachedEnd {
60
+ let chunk = try FileUtilities.readUpToCount(
61
+ from: input,
62
+ count: bufferSize
63
+ ) ?? Data()
64
+ let status: compression_status
65
+ if chunk.isEmpty {
66
+ stream.src_ptr = UnsafePointer<UInt8>(bitPattern: 1)!
67
+ stream.src_size = 0
68
+ status = try process(
69
+ &stream,
70
+ output: output,
71
+ outputBuffer: outputBuffer,
72
+ finalize: true,
73
+ totalOutputByteSize: &totalOutputByteSize,
74
+ maximumOutputByteSize: maximumOutputByteSize
75
+ )
76
+ } else {
77
+ status = try chunk.withUnsafeBytes { bytes in
78
+ guard let source = bytes.baseAddress?
79
+ .assumingMemoryBound(to: UInt8.self) else {
80
+ return COMPRESSION_STATUS_OK
81
+ }
82
+ stream.src_ptr = source
83
+ stream.src_size = chunk.count
84
+ return try process(
85
+ &stream,
86
+ output: output,
87
+ outputBuffer: outputBuffer,
88
+ finalize: false,
89
+ totalOutputByteSize: &totalOutputByteSize,
90
+ maximumOutputByteSize: maximumOutputByteSize
91
+ )
92
+ }
93
+ }
94
+
95
+ if status == COMPRESSION_STATUS_END {
96
+ guard stream.src_size == 0 else {
97
+ throw error(7, "Brotli stream contains trailing compressed bytes")
98
+ }
99
+ let trailing = try FileUtilities.readUpToCount(from: input, count: 1)
100
+ guard trailing?.isEmpty != false else {
101
+ throw error(7, "Brotli stream contains trailing compressed bytes")
102
+ }
103
+ reachedEnd = true
104
+ } else if chunk.isEmpty {
105
+ throw error(3, "Brotli input ended before the stream completed")
106
+ }
107
+ }
108
+
109
+ if let maximumOutputByteSize,
110
+ totalOutputByteSize != maximumOutputByteSize {
111
+ throw error(
112
+ 8,
113
+ "Brotli output size mismatch: expected \(maximumOutputByteSize), got \(totalOutputByteSize)"
114
+ )
115
+ }
116
+ }
117
+
118
+ private static func process(
119
+ _ stream: inout compression_stream,
120
+ output: FileHandle,
121
+ outputBuffer: UnsafeMutablePointer<UInt8>,
122
+ finalize: Bool,
123
+ totalOutputByteSize: inout UInt64,
124
+ maximumOutputByteSize: UInt64?
125
+ ) throws -> compression_status {
126
+ let flags = finalize ? Int32(COMPRESSION_STREAM_FINALIZE.rawValue) : 0
127
+ var status = COMPRESSION_STATUS_OK
128
+ repeat {
129
+ let previousSourceSize = stream.src_size
130
+ stream.dst_ptr = outputBuffer
131
+ stream.dst_size = bufferSize
132
+ status = compression_stream_process(&stream, flags)
133
+
134
+ guard status == COMPRESSION_STATUS_OK ||
135
+ status == COMPRESSION_STATUS_END else {
136
+ throw error(4, "Brotli decompression failed")
137
+ }
138
+ let produced = bufferSize - stream.dst_size
139
+ if produced > 0 {
140
+ let (nextOutputByteSize, overflowed) = totalOutputByteSize
141
+ .addingReportingOverflow(UInt64(produced))
142
+ guard !overflowed,
143
+ maximumOutputByteSize.map({ nextOutputByteSize <= $0 }) ?? true else {
144
+ throw error(6, "Brotli output exceeds expected size")
145
+ }
146
+ output.write(Data(bytes: outputBuffer, count: produced))
147
+ totalOutputByteSize = nextOutputByteSize
148
+ }
149
+ if finalize,
150
+ status == COMPRESSION_STATUS_OK,
151
+ produced == 0,
152
+ previousSourceSize == stream.src_size {
153
+ throw error(5, "Brotli decompression stalled")
154
+ }
155
+ } while stream.src_size > 0 || stream.dst_size == 0 ||
156
+ (finalize && status == COMPRESSION_STATUS_OK)
157
+ return status
158
+ }
159
+
160
+ private static func error(_ code: Int, _ message: String) -> NSError {
161
+ NSError(
162
+ domain: "BrotliFileDecompressor",
163
+ code: code,
164
+ userInfo: [NSLocalizedDescriptionKey: message]
165
+ )
166
+ }
167
+ }
@@ -0,0 +1,196 @@
1
+ import Foundation
2
+
3
+ protocol BuiltInAssetResolver: AnyObject {
4
+ func use(bundle: Bundle)
5
+ func copyIfMatches(assetPath: String, expectedHash: String, destination: String) -> Bool
6
+ }
7
+
8
+ final class IOSBuiltInAssetResolver: BuiltInAssetResolver {
9
+ private struct CachedIndex: Codable {
10
+ let schemaVersion: Int
11
+ let packageIdentity: String
12
+ var locators: [String: String]
13
+ }
14
+
15
+ private let cacheURL: URL
16
+ private let lock = NSLock()
17
+ private var bundle: Bundle = .main
18
+
19
+ init(cacheURL: URL) {
20
+ self.cacheURL = cacheURL
21
+ }
22
+
23
+ func use(bundle: Bundle) {
24
+ lock.lock()
25
+ self.bundle = bundle
26
+ lock.unlock()
27
+ }
28
+
29
+ func copyIfMatches(
30
+ assetPath: String,
31
+ expectedHash: String,
32
+ destination: String
33
+ ) -> Bool {
34
+ lock.lock()
35
+ defer { lock.unlock() }
36
+
37
+ guard FileUtilities.normalizedRelativePath(from: assetPath) == assetPath else {
38
+ return false
39
+ }
40
+ let packageIdentity = packageIdentity(for: bundle)
41
+ var index = readIndex(packageIdentity: packageIdentity)
42
+
43
+ if let cachedLocator = index.locators[assetPath],
44
+ copyLocatorIfMatches(
45
+ cachedLocator,
46
+ expectedHash: expectedHash,
47
+ destination: destination
48
+ ) {
49
+ return true
50
+ }
51
+
52
+ guard let locator = resolveLocator(assetPath: assetPath),
53
+ copyLocatorIfMatches(
54
+ locator,
55
+ expectedHash: expectedHash,
56
+ destination: destination
57
+ ) else {
58
+ return false
59
+ }
60
+
61
+ index.locators[assetPath] = locator
62
+ writeIndex(index)
63
+ return true
64
+ }
65
+
66
+ private func resolveLocator(assetPath: String) -> String? {
67
+ if assetPath == "index.ios.bundle" {
68
+ guard let bundleURL = bundle.url(forResource: "main", withExtension: "jsbundle"),
69
+ let relativePath = relativePath(of: bundleURL, inside: bundle.bundleURL) else {
70
+ return nil
71
+ }
72
+ return "bundle:\(relativePath)"
73
+ }
74
+
75
+ guard let resourceURL = bundle.resourceURL else { return nil }
76
+ let sourceURL = resourceURL.appendingPathComponent(assetPath).standardizedFileURL
77
+ guard relativePath(of: sourceURL, inside: resourceURL) == assetPath,
78
+ isRegularFile(sourceURL) else {
79
+ return nil
80
+ }
81
+ return "resource:\(assetPath)"
82
+ }
83
+
84
+ private func copyLocatorIfMatches(
85
+ _ locator: String,
86
+ expectedHash: String,
87
+ destination: String
88
+ ) -> Bool {
89
+ guard let sourceURL = sourceURL(for: locator), isRegularFile(sourceURL) else {
90
+ return false
91
+ }
92
+ let destinationURL = URL(fileURLWithPath: destination)
93
+ let temporaryURL = destinationURL
94
+ .deletingLastPathComponent()
95
+ .appendingPathComponent("\(destinationURL.lastPathComponent).builtin.tmp")
96
+ do {
97
+ try FileManager.default.createDirectory(
98
+ at: destinationURL.deletingLastPathComponent(),
99
+ withIntermediateDirectories: true
100
+ )
101
+ try? FileManager.default.removeItem(at: temporaryURL)
102
+ try FileManager.default.copyItem(at: sourceURL, to: temporaryURL)
103
+ guard HashUtils.verifyHash(fileURL: temporaryURL, expectedHash: expectedHash) else {
104
+ try? FileManager.default.removeItem(at: temporaryURL)
105
+ return false
106
+ }
107
+ try? FileManager.default.removeItem(at: destinationURL)
108
+ try FileManager.default.moveItem(at: temporaryURL, to: destinationURL)
109
+ return true
110
+ } catch {
111
+ try? FileManager.default.removeItem(at: temporaryURL)
112
+ return false
113
+ }
114
+ }
115
+
116
+ private func sourceURL(for locator: String) -> URL? {
117
+ if locator.hasPrefix("bundle:") {
118
+ let relativePath = String(locator.dropFirst("bundle:".count))
119
+ let sourceURL = bundle.bundleURL.appendingPathComponent(relativePath).standardizedFileURL
120
+ return self.relativePath(of: sourceURL, inside: bundle.bundleURL) == relativePath
121
+ ? sourceURL
122
+ : nil
123
+ }
124
+ if locator.hasPrefix("resource:"), let resourceURL = bundle.resourceURL {
125
+ let relativePath = String(locator.dropFirst("resource:".count))
126
+ let sourceURL = resourceURL.appendingPathComponent(relativePath).standardizedFileURL
127
+ return self.relativePath(of: sourceURL, inside: resourceURL) == relativePath
128
+ ? sourceURL
129
+ : nil
130
+ }
131
+ return nil
132
+ }
133
+
134
+ private func relativePath(of fileURL: URL, inside directoryURL: URL) -> String? {
135
+ let directory = directoryURL.standardizedFileURL.resolvingSymlinksInPath().path
136
+ let file = fileURL.standardizedFileURL.resolvingSymlinksInPath().path
137
+ let prefix = directory.hasSuffix("/") ? directory : "\(directory)/"
138
+ guard file.hasPrefix(prefix) else { return nil }
139
+ return String(file.dropFirst(prefix.count))
140
+ }
141
+
142
+ private func isRegularFile(_ url: URL) -> Bool {
143
+ (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
144
+ }
145
+
146
+ private func packageIdentity(for bundle: Bundle) -> String {
147
+ let values = try? bundle.bundleURL.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
148
+ let bundleIdentifier = bundle.bundleIdentifier ?? ""
149
+ let shortVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
150
+ let bundleVersion = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
151
+ let bundlePath = bundle.bundleURL.standardizedFileURL.path
152
+ let fileSize = String(values?.fileSize ?? -1)
153
+ let modificationTime = String(values?.contentModificationDate?.timeIntervalSince1970 ?? -1)
154
+
155
+ return [
156
+ bundleIdentifier,
157
+ shortVersion,
158
+ bundleVersion,
159
+ bundlePath,
160
+ fileSize,
161
+ modificationTime,
162
+ ].joined(separator: "|")
163
+ }
164
+
165
+ private func readIndex(packageIdentity: String) -> CachedIndex {
166
+ if let data = try? Data(contentsOf: cacheURL),
167
+ let index = try? JSONDecoder().decode(CachedIndex.self, from: data),
168
+ index.schemaVersion == Self.schemaVersion,
169
+ index.packageIdentity == packageIdentity {
170
+ return index
171
+ }
172
+ return CachedIndex(
173
+ schemaVersion: Self.schemaVersion,
174
+ packageIdentity: packageIdentity,
175
+ locators: [:]
176
+ )
177
+ }
178
+
179
+ private func writeIndex(_ index: CachedIndex) {
180
+ do {
181
+ try FileManager.default.createDirectory(
182
+ at: cacheURL.deletingLastPathComponent(),
183
+ withIntermediateDirectories: true
184
+ )
185
+ let temporaryURL = cacheURL.appendingPathExtension("tmp")
186
+ let data = try JSONEncoder().encode(index)
187
+ try data.write(to: temporaryURL, options: .atomic)
188
+ try? FileManager.default.removeItem(at: cacheURL)
189
+ try FileManager.default.moveItem(at: temporaryURL, to: cacheURL)
190
+ } catch {
191
+ // Cache persistence must never block a valid install.
192
+ }
193
+ }
194
+
195
+ private static let schemaVersion = 1
196
+ }