@onekeyfe/react-native-range-downloader 3.0.79 → 3.0.81-alpha.0

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 (113) hide show
  1. package/ReactNativeRangeDownloader.podspec +8 -0
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt +284 -0
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +842 -0
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +148 -0
  6. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt +75 -0
  7. package/ios/FirmwareArchiveMinizipBridge.h +33 -0
  8. package/ios/FirmwareArchiveMinizipBridge.mm +213 -0
  9. package/ios/FirmwareArtifactStore.swift +1276 -0
  10. package/ios/FirmwareBackgroundSessionEventRouter.swift +17 -0
  11. package/ios/ReactNativeRangeDownloader.swift +638 -6
  12. package/lib/module/index.js +2 -1
  13. package/lib/module/index.js.map +1 -1
  14. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +95 -1
  15. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  16. package/lib/typescript/src/index.d.ts +1 -0
  17. package/lib/typescript/src/index.d.ts.map +1 -1
  18. package/nitrogen/generated/android/c++/JDownloadChannel.hpp +3 -0
  19. package/nitrogen/generated/android/c++/JFirmwareArchiveExpectedEntry.hpp +69 -0
  20. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeParams.hpp +86 -0
  21. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeResult.hpp +80 -0
  22. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializedArtifact.hpp +63 -0
  23. package/nitrogen/generated/android/c++/JFirmwareArtifactCancelParams.hpp +57 -0
  24. package/nitrogen/generated/android/c++/JFirmwareArtifactCapabilities.hpp +88 -0
  25. package/nitrogen/generated/android/c++/JFirmwareArtifactDownloadParams.hpp +98 -0
  26. package/nitrogen/generated/android/c++/JFirmwareArtifactLease.hpp +57 -0
  27. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseCreateParams.hpp +57 -0
  28. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseReconcileParams.hpp +76 -0
  29. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseReleaseParams.hpp +61 -0
  30. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseRetainParams.hpp +61 -0
  31. package/nitrogen/generated/android/c++/JFirmwareArtifactReaderCloseParams.hpp +57 -0
  32. package/nitrogen/generated/android/c++/JFirmwareArtifactReaderInfo.hpp +61 -0
  33. package/nitrogen/generated/android/c++/JFirmwareArtifactReaderReadParams.hpp +65 -0
  34. package/nitrogen/generated/android/c++/JFirmwareArtifactReceipt.hpp +65 -0
  35. package/nitrogen/generated/android/c++/JFirmwareArtifactRefParams.hpp +57 -0
  36. package/nitrogen/generated/android/c++/JFirmwareArtifactSweepResult.hpp +61 -0
  37. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +267 -0
  38. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +13 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/DownloadChannel.kt +2 -1
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveExpectedEntry.kt +47 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeParams.kt +44 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeResult.kt +38 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializedArtifact.kt +41 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactCancelParams.kt +38 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactCapabilities.kt +47 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDownloadParams.kt +68 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLease.kt +38 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseCreateParams.kt +38 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseReconcileParams.kt +38 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseReleaseParams.kt +41 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseRetainParams.kt +41 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReaderCloseParams.kt +38 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReaderInfo.kt +41 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReaderReadParams.kt +44 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReceipt.kt +44 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactRefParams.kt +38 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactSweepResult.kt +41 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +53 -0
  59. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.cpp +48 -0
  60. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +329 -0
  61. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +56 -0
  62. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +163 -0
  63. package/nitrogen/generated/ios/swift/DownloadChannel.swift +4 -0
  64. package/nitrogen/generated/ios/swift/FirmwareArchiveExpectedEntry.swift +69 -0
  65. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeParams.swift +70 -0
  66. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeResult.swift +48 -0
  67. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializedArtifact.swift +47 -0
  68. package/nitrogen/generated/ios/swift/FirmwareArtifactCancelParams.swift +36 -0
  69. package/nitrogen/generated/ios/swift/FirmwareArtifactCapabilities.swift +81 -0
  70. package/nitrogen/generated/ios/swift/FirmwareArtifactDownloadParams.swift +177 -0
  71. package/nitrogen/generated/ios/swift/FirmwareArtifactLease.swift +36 -0
  72. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseCreateParams.swift +36 -0
  73. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseReconcileParams.swift +48 -0
  74. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseReleaseParams.swift +47 -0
  75. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseRetainParams.swift +47 -0
  76. package/nitrogen/generated/ios/swift/FirmwareArtifactReaderCloseParams.swift +36 -0
  77. package/nitrogen/generated/ios/swift/FirmwareArtifactReaderInfo.swift +47 -0
  78. package/nitrogen/generated/ios/swift/FirmwareArtifactReaderReadParams.swift +58 -0
  79. package/nitrogen/generated/ios/swift/FirmwareArtifactReceipt.swift +58 -0
  80. package/nitrogen/generated/ios/swift/FirmwareArtifactRefParams.swift +36 -0
  81. package/nitrogen/generated/ios/swift/FirmwareArtifactSweepResult.swift +47 -0
  82. package/nitrogen/generated/ios/swift/Func_void_FirmwareArchiveMaterializeResult.swift +47 -0
  83. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactLease.swift +47 -0
  84. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactReaderInfo.swift +47 -0
  85. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactReceipt.swift +47 -0
  86. package/nitrogen/generated/ios/swift/Func_void_FirmwareArtifactSweepResult.swift +47 -0
  87. package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_ArrayBuffer_.swift +47 -0
  88. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +13 -0
  89. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +240 -0
  90. package/nitrogen/generated/shared/c++/DownloadChannel.hpp +4 -0
  91. package/nitrogen/generated/shared/c++/FirmwareArchiveExpectedEntry.hpp +87 -0
  92. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeParams.hpp +86 -0
  93. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeResult.hpp +77 -0
  94. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializedArtifact.hpp +81 -0
  95. package/nitrogen/generated/shared/c++/FirmwareArtifactCancelParams.hpp +75 -0
  96. package/nitrogen/generated/shared/c++/FirmwareArtifactCapabilities.hpp +88 -0
  97. package/nitrogen/generated/shared/c++/FirmwareArtifactDownloadParams.hpp +116 -0
  98. package/nitrogen/generated/shared/c++/FirmwareArtifactLease.hpp +75 -0
  99. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseCreateParams.hpp +75 -0
  100. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseReconcileParams.hpp +76 -0
  101. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseReleaseParams.hpp +79 -0
  102. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseRetainParams.hpp +79 -0
  103. package/nitrogen/generated/shared/c++/FirmwareArtifactReaderCloseParams.hpp +75 -0
  104. package/nitrogen/generated/shared/c++/FirmwareArtifactReaderInfo.hpp +79 -0
  105. package/nitrogen/generated/shared/c++/FirmwareArtifactReaderReadParams.hpp +83 -0
  106. package/nitrogen/generated/shared/c++/FirmwareArtifactReceipt.hpp +83 -0
  107. package/nitrogen/generated/shared/c++/FirmwareArtifactRefParams.hpp +75 -0
  108. package/nitrogen/generated/shared/c++/FirmwareArtifactSweepResult.hpp +79 -0
  109. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +13 -0
  110. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +62 -0
  111. package/package.json +4 -2
  112. package/src/ReactNativeRangeDownloader.nitro.ts +134 -1
  113. package/src/index.tsx +3 -1
@@ -0,0 +1,842 @@
1
+ package com.margelo.nitro.reactnativerangedownloader
2
+
3
+ import android.system.Os
4
+ import com.margelo.nitro.NitroModules
5
+ import com.sniconnect.SniPinnedTransport
6
+ import java.io.BufferedInputStream
7
+ import java.io.File
8
+ import java.io.FileInputStream
9
+ import java.io.FileOutputStream
10
+ import java.io.IOException
11
+ import java.io.RandomAccessFile
12
+ import java.security.MessageDigest
13
+ import java.util.UUID
14
+ import java.util.concurrent.ConcurrentHashMap
15
+ import java.util.concurrent.TimeUnit
16
+ import java.util.zip.ZipInputStream
17
+ import okhttp3.Call
18
+ import okhttp3.OkHttpClient
19
+ import okhttp3.Protocol
20
+ import okhttp3.Request
21
+ import okhttp3.Response
22
+ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
23
+ import org.json.JSONArray
24
+ import org.json.JSONObject
25
+
26
+ internal data class StoredFirmwareArtifact(
27
+ val artifactRef: String,
28
+ val size: Long,
29
+ val sha256: String,
30
+ val file: File,
31
+ )
32
+
33
+ internal data class StoredFirmwareArchiveEntry(
34
+ val entryName: String,
35
+ val artifact: StoredFirmwareArtifact,
36
+ )
37
+
38
+ private data class StagedFirmwareArchiveEntry(
39
+ val entryName: String,
40
+ val size: Long,
41
+ val sha256: String,
42
+ val file: File,
43
+ )
44
+
45
+ private data class FirmwareDownloadKey(
46
+ val transactionId: String,
47
+ val expectedSha256: String,
48
+ )
49
+
50
+ internal object FirmwareArtifactStore {
51
+ const val MAX_READ_BYTES = 256 * 1024
52
+
53
+ private const val MAX_ARTIFACT_BYTES = 512L * 1024 * 1024
54
+ private const val MAX_LEASE_METADATA_BYTES = 1024L * 1024
55
+ private const val MAX_TOTAL_LEASE_REFS = 8192
56
+ private const val FINAL_ARTIFACT_GRACE_MS = 24L * 60 * 60 * 1000
57
+ private const val PARTIAL_ARTIFACT_GRACE_MS = 7L * 24 * 60 * 60 * 1000
58
+ private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$")
59
+ private val artifactRefPattern = Regex("^fw:[a-f0-9]{64}$")
60
+ private val leaseRefPattern = Regex("^fwlease:[a-f0-9-]{36}$")
61
+ private val identifierPattern = Regex("^[A-Za-z0-9._:-]{1,160}$")
62
+ private val downloadLocks = ConcurrentHashMap<FirmwareDownloadKey, Any>()
63
+ private val activeCalls =
64
+ ConcurrentHashMap<String, MutableSet<Call>>()
65
+ private val cancelledTransactions =
66
+ ConcurrentHashMap.newKeySet<String>()
67
+ private val activeDownloadLock = Any()
68
+ private val activeDownloadCounts = mutableMapOf<String, Int>()
69
+ private val leaseLock = Any()
70
+ private val readerLock = Any()
71
+ private val readers = mutableMapOf<String, OpenReader>()
72
+
73
+ private data class LeaseState(
74
+ val transactionId: String,
75
+ val artifactRefs: MutableSet<String>,
76
+ )
77
+
78
+ private data class OpenReader(
79
+ val file: File,
80
+ val handle: RandomAccessFile,
81
+ val size: Long,
82
+ )
83
+
84
+ private val root: File
85
+ get() {
86
+ val context = NitroModules.applicationContext
87
+ ?: error("Application context is not available")
88
+ return File(context.filesDir, "onekey-firmware-artifacts").also {
89
+ check(it.exists() || it.mkdirs()) {
90
+ "Firmware artifact directory cannot be created"
91
+ }
92
+ }
93
+ }
94
+
95
+ fun download(params: FirmwareArtifactDownloadParams): StoredFirmwareArtifact {
96
+ val validated = validateDownloadParams(params)
97
+ check(!cancelledTransactions.contains(params.transactionId)) {
98
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
99
+ }
100
+ retainExpectedArtifact(
101
+ leaseRef = params.leaseRef,
102
+ transactionId = params.transactionId,
103
+ artifactRef = "fw:${validated.expectedSha256}",
104
+ )
105
+ val lockKey = FirmwareDownloadKey(
106
+ params.transactionId,
107
+ validated.expectedSha256,
108
+ )
109
+ val lock = downloadLocks.computeIfAbsent(lockKey) { Any() }
110
+ markDownloadActive(validated.expectedSha256, 1)
111
+ try {
112
+ return synchronized(lock) {
113
+ check(!cancelledTransactions.contains(params.transactionId)) {
114
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
115
+ }
116
+ downloadLocked(params, validated)
117
+ }
118
+ } finally {
119
+ markDownloadActive(validated.expectedSha256, -1)
120
+ }
121
+ }
122
+
123
+ fun cancelDownloads(transactionId: String) {
124
+ require(identifierPattern.matches(transactionId)) {
125
+ "Invalid firmware transactionId"
126
+ }
127
+ cancelledTransactions.add(transactionId)
128
+ activeCalls[transactionId]
129
+ ?.toList()
130
+ ?.forEach { it.cancel() }
131
+ }
132
+
133
+ fun discard(artifactRef: String) {
134
+ val file = resolveArtifactFile(artifactRef)
135
+ synchronized(leaseLock) {
136
+ require(loadLeasesLocked().values.none { artifactRef in it.artifactRefs }) {
137
+ "ARTIFACT_LEASED: firmware artifact is retained"
138
+ }
139
+ }
140
+ synchronized(readerLock) {
141
+ require(readers.none { it.value.file == file }) {
142
+ "ARTIFACT_BUSY: firmware artifact has an open reader"
143
+ }
144
+ }
145
+ if (file.exists() && !file.delete()) {
146
+ error("Firmware artifact cannot be discarded")
147
+ }
148
+ }
149
+
150
+ fun open(artifactRef: String): Pair<String, Long> {
151
+ val file = resolveArtifactFile(artifactRef)
152
+ val size = file.length()
153
+ require(size > 0) { "Firmware artifact is empty" }
154
+ require(hashFile(file) == artifactRef.removePrefix("fw:")) {
155
+ "Firmware artifact SHA-256 mismatch"
156
+ }
157
+ val readerId = UUID.randomUUID().toString()
158
+ synchronized(readerLock) {
159
+ readers[readerId] = OpenReader(file, RandomAccessFile(file, "r"), size)
160
+ }
161
+ return readerId to size
162
+ }
163
+
164
+ fun read(readerId: String, offset: Long, length: Int): ByteArray {
165
+ require(offset >= 0 && length in 1..MAX_READ_BYTES) {
166
+ "Invalid firmware artifact read"
167
+ }
168
+ return synchronized(readerLock) {
169
+ val reader = readers[readerId] ?: error("Firmware artifact reader is invalid")
170
+ require(offset <= reader.size - length) {
171
+ "Firmware artifact read is out of bounds"
172
+ }
173
+ val data = ByteArray(length)
174
+ reader.handle.seek(offset)
175
+ reader.handle.readFully(data)
176
+ data
177
+ }
178
+ }
179
+
180
+ fun close(readerId: String) {
181
+ synchronized(readerLock) {
182
+ readers.remove(readerId)?.handle?.close()
183
+ }
184
+ }
185
+
186
+ fun materializeArchive(
187
+ leaseRef: String,
188
+ artifactRef: String,
189
+ expectedEntries: Array<FirmwareArchiveExpectedEntry>,
190
+ ): List<StoredFirmwareArchiveEntry> {
191
+ requireLease(leaseRef)
192
+ val archiveFile = resolveArtifactFile(artifactRef)
193
+ val requirements = FirmwareArchiveRules.validateRequirements(expectedEntries)
194
+ val centralEntries = FirmwareArchiveRules.validateCentralDirectory(
195
+ archiveFile,
196
+ requirements,
197
+ )
198
+ val requirementsByName = requirements.associateBy { it.entryName }
199
+ val centralNames = centralEntries.mapTo(mutableSetOf()) { it.name }
200
+ val scratchDir = File(root, "archive-${UUID.randomUUID()}")
201
+ check(scratchDir.mkdirs()) { "Firmware archive scratch directory cannot be created" }
202
+ try {
203
+ val staged = mutableListOf<StagedFirmwareArchiveEntry>()
204
+ val entryNames = mutableSetOf<String>()
205
+ ZipInputStream(BufferedInputStream(FileInputStream(archiveFile))).use { zip ->
206
+ while (true) {
207
+ val zipEntry = zip.nextEntry ?: break
208
+ require(!zipEntry.isDirectory) {
209
+ "Firmware archive contains an unexpected directory"
210
+ }
211
+ val requirement = requirementsByName[zipEntry.name]
212
+ ?: error("Firmware archive contains an unexpected entry")
213
+ require(
214
+ centralNames.contains(zipEntry.name) &&
215
+ entryNames.add(zipEntry.name)
216
+ ) {
217
+ "Firmware archive contains a duplicate or mismatched entry"
218
+ }
219
+ val expectedSize = requirement.expectedSize.toLong()
220
+ val expectedSha256 = requirement.expectedSha256.lowercase()
221
+ val scratchFile = File(scratchDir, "${staged.size}.entry")
222
+ val digest = MessageDigest.getInstance("SHA-256")
223
+ var entrySize = 0L
224
+ RandomAccessFile(scratchFile, "rw").use { output ->
225
+ val buffer = ByteArray(64 * 1024)
226
+ while (true) {
227
+ val count = zip.read(buffer)
228
+ if (count < 0) break
229
+ if (count == 0) continue
230
+ entrySize = Math.addExact(entrySize, count.toLong())
231
+ require(entrySize <= expectedSize) {
232
+ "Firmware archive entry exceeds its expected size"
233
+ }
234
+ output.write(buffer, 0, count)
235
+ digest.update(buffer, 0, count)
236
+ }
237
+ output.fd.sync()
238
+ }
239
+ val sha256 = digest.digest().toHex()
240
+ require(entrySize == expectedSize && sha256 == expectedSha256) {
241
+ "Firmware archive entry integrity mismatch"
242
+ }
243
+ staged += StagedFirmwareArchiveEntry(
244
+ entryName = requirement.entryName,
245
+ size = entrySize,
246
+ sha256 = sha256,
247
+ file = scratchFile,
248
+ )
249
+ zip.closeEntry()
250
+ }
251
+ }
252
+ require(entryNames == centralNames) {
253
+ "Firmware archive has missing or extra entries"
254
+ }
255
+ return staged.map { entry ->
256
+ val destination = artifactFile(entry.sha256)
257
+ val stored = validateStoredArtifactOrNull(
258
+ destination,
259
+ entry.size,
260
+ entry.sha256,
261
+ ) ?: run {
262
+ promoteAtomically(entry.file, destination)
263
+ StoredFirmwareArtifact(
264
+ "fw:${entry.sha256}",
265
+ entry.size,
266
+ entry.sha256,
267
+ destination,
268
+ )
269
+ }
270
+ retainExpectedArtifact(
271
+ leaseRef = leaseRef,
272
+ transactionId = null,
273
+ artifactRef = stored.artifactRef,
274
+ )
275
+ StoredFirmwareArchiveEntry(entry.entryName, stored)
276
+ }
277
+ } finally {
278
+ scratchDir.deleteRecursively()
279
+ }
280
+ }
281
+
282
+ private data class ValidatedDownload(
283
+ val expectedSize: Long,
284
+ val maxBytes: Long,
285
+ val expectedSha256: String,
286
+ val hostname: String,
287
+ )
288
+
289
+ private fun validateDownloadParams(
290
+ params: FirmwareArtifactDownloadParams,
291
+ ): ValidatedDownload {
292
+ require(
293
+ params.taskId.isNotEmpty() &&
294
+ params.taskId.length <= 100 &&
295
+ params.taskId.all { it.isLetterOrDigit() || it in "._-" }
296
+ ) {
297
+ "Invalid firmware taskId"
298
+ }
299
+ require(identifierPattern.matches(params.transactionId)) {
300
+ "Invalid firmware transactionId"
301
+ }
302
+ require(identifierPattern.matches(params.artifactId)) {
303
+ "Invalid firmware artifactId"
304
+ }
305
+ require(leaseRefPattern.matches(params.leaseRef)) {
306
+ "Invalid firmware leaseRef"
307
+ }
308
+ val url = params.url.toHttpUrlOrNull()
309
+ ?: throw IllegalArgumentException("Invalid firmware URL")
310
+ require(
311
+ url.isHttps &&
312
+ url.port == 443 &&
313
+ url.username.isEmpty() &&
314
+ url.password.isEmpty() &&
315
+ url.fragment == null
316
+ ) {
317
+ "Firmware URL must use HTTPS port 443"
318
+ }
319
+ val expectedSize = params.expectedSize.toExactPositiveLong("expectedSize")
320
+ val maxBytes = params.maxBytes.toExactPositiveLong("maxBytes")
321
+ require(maxBytes == expectedSize && maxBytes <= MAX_ARTIFACT_BYTES) {
322
+ "Invalid firmware maxBytes"
323
+ }
324
+ require(sha256Pattern.matches(params.expectedSha256)) {
325
+ "Invalid firmware artifact SHA-256"
326
+ }
327
+ require(params.routeType == "domain" || params.routeType == "pinnedIp") {
328
+ "Invalid firmware route type"
329
+ }
330
+ if (params.routeType == "pinnedIp") {
331
+ require(!params.resolvedIp.isNullOrEmpty()) {
332
+ "Pinned route requires resolvedIp"
333
+ }
334
+ } else {
335
+ require(params.resolvedIp == null) {
336
+ "Domain route must not include resolvedIp"
337
+ }
338
+ }
339
+ return ValidatedDownload(
340
+ expectedSize = expectedSize,
341
+ maxBytes = maxBytes,
342
+ expectedSha256 = params.expectedSha256.lowercase(),
343
+ hostname = url.host,
344
+ )
345
+ }
346
+
347
+ private fun downloadLocked(
348
+ params: FirmwareArtifactDownloadParams,
349
+ validated: ValidatedDownload,
350
+ ): StoredFirmwareArtifact {
351
+ val finalFile = artifactFile(validated.expectedSha256)
352
+ validateStoredArtifactOrNull(
353
+ finalFile,
354
+ validated.expectedSize,
355
+ validated.expectedSha256,
356
+ )?.let { return it }
357
+
358
+ val partialFile = File(
359
+ root,
360
+ "${validated.expectedSha256}.${params.taskId}.partial",
361
+ )
362
+ if (partialFile.length() > validated.expectedSize) {
363
+ check(partialFile.delete()) { "Invalid firmware partial cannot be removed" }
364
+ }
365
+ if (partialFile.length() == validated.expectedSize) {
366
+ validateStoredArtifactOrNull(
367
+ partialFile,
368
+ validated.expectedSize,
369
+ validated.expectedSha256,
370
+ )?.let {
371
+ promoteAtomically(partialFile, finalFile)
372
+ return StoredFirmwareArtifact(
373
+ it.artifactRef,
374
+ it.size,
375
+ it.sha256,
376
+ finalFile,
377
+ )
378
+ }
379
+ check(partialFile.delete()) { "Invalid firmware partial cannot be removed" }
380
+ }
381
+
382
+ val resumeOffset = partialFile.length()
383
+ val requestBuilder = Request.Builder()
384
+ .url(params.url)
385
+ .header("Accept-Encoding", "identity")
386
+ if (resumeOffset > 0) {
387
+ requestBuilder.header("Range", "bytes=$resumeOffset-")
388
+ }
389
+
390
+ val client = if (params.routeType == "pinnedIp") {
391
+ SniPinnedTransport.createClient(
392
+ ip = checkNotNull(params.resolvedIp),
393
+ hostname = validated.hostname,
394
+ )
395
+ } else {
396
+ OkHttpClient.Builder()
397
+ .protocols(listOf(Protocol.HTTP_1_1))
398
+ .followRedirects(false)
399
+ .followSslRedirects(false)
400
+ .build()
401
+ }
402
+ val call = client.newCall(requestBuilder.build())
403
+ params.overallDeadlineSeconds?.let { deadline ->
404
+ require(deadline.isFinite() && deadline > 0) {
405
+ "Invalid firmware download deadline"
406
+ }
407
+ call.timeout().timeout(deadline.toLong().coerceAtLeast(1), TimeUnit.SECONDS)
408
+ }
409
+
410
+ registerCall(params.transactionId, call)
411
+ try {
412
+ call.execute().use {
413
+ writeResponseToPartial(
414
+ it,
415
+ partialFile,
416
+ resumeOffset,
417
+ validated.expectedSize,
418
+ validated.maxBytes,
419
+ )
420
+ }
421
+ } catch (error: IOException) {
422
+ if (
423
+ call.isCanceled() ||
424
+ cancelledTransactions.contains(params.transactionId)
425
+ ) {
426
+ throw IllegalStateException(
427
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled",
428
+ error,
429
+ )
430
+ }
431
+ throw IllegalStateException(
432
+ "ARTIFACT_NETWORK_FAILED: firmware request failed",
433
+ error,
434
+ )
435
+ } finally {
436
+ unregisterCall(params.transactionId, call)
437
+ }
438
+ check(!cancelledTransactions.contains(params.transactionId)) {
439
+ "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
440
+ }
441
+
442
+ val artifact = try {
443
+ validateStoredArtifact(
444
+ partialFile,
445
+ validated.expectedSize,
446
+ validated.expectedSha256,
447
+ )
448
+ } catch (error: Throwable) {
449
+ partialFile.delete()
450
+ throw error
451
+ }
452
+ promoteAtomically(partialFile, finalFile)
453
+ return StoredFirmwareArtifact(
454
+ artifact.artifactRef,
455
+ artifact.size,
456
+ artifact.sha256,
457
+ finalFile,
458
+ )
459
+ }
460
+
461
+ private fun writeResponseToPartial(
462
+ response: Response,
463
+ partialFile: File,
464
+ resumeOffset: Long,
465
+ expectedSize: Long,
466
+ maxBytes: Long,
467
+ ) {
468
+ require(response.code == 200 || response.code == 206) {
469
+ "ARTIFACT_HTTP_${response.code}: firmware request failed"
470
+ }
471
+ val append = resumeOffset > 0 && response.code == 206
472
+ if (response.code == 206) {
473
+ require(
474
+ validateContentRange(
475
+ response.header("Content-Range"),
476
+ if (append) resumeOffset else 0,
477
+ expectedSize,
478
+ )
479
+ ) {
480
+ "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid"
481
+ }
482
+ }
483
+ val body = response.body ?: error("Firmware response has no body")
484
+ RandomAccessFile(partialFile, "rw").use { output ->
485
+ if (append) {
486
+ output.seek(resumeOffset)
487
+ } else {
488
+ output.setLength(0)
489
+ }
490
+ var written = if (append) resumeOffset else 0L
491
+ val source = body.source()
492
+ val buffer = ByteArray(64 * 1024)
493
+ while (true) {
494
+ val count = source.read(buffer)
495
+ if (count < 0) break
496
+ if (count == 0) continue
497
+ written += count
498
+ require(written <= maxBytes) {
499
+ "ARTIFACT_PROTOCOL_INVALID: firmware artifact exceeds maxBytes"
500
+ }
501
+ output.write(buffer, 0, count)
502
+ }
503
+ output.fd.sync()
504
+ }
505
+ }
506
+
507
+ private fun validateContentRange(
508
+ value: String?,
509
+ expectedStart: Long,
510
+ expectedTotal: Long,
511
+ ): Boolean {
512
+ val match = value
513
+ ?.lowercase()
514
+ ?.let { Regex("^bytes ([0-9]+)-([0-9]+)/([0-9]+)$").matchEntire(it) }
515
+ ?: return false
516
+ val start = match.groupValues[1].toLongOrNull() ?: return false
517
+ val end = match.groupValues[2].toLongOrNull() ?: return false
518
+ val total = match.groupValues[3].toLongOrNull() ?: return false
519
+ return start == expectedStart &&
520
+ end >= start &&
521
+ end < total &&
522
+ total == expectedTotal
523
+ }
524
+
525
+ private fun validateStoredArtifactOrNull(
526
+ file: File,
527
+ expectedSize: Long,
528
+ expectedSha256: String,
529
+ ): StoredFirmwareArtifact? = try {
530
+ validateStoredArtifact(file, expectedSize, expectedSha256)
531
+ } catch (_: Throwable) {
532
+ null
533
+ }
534
+
535
+ private fun validateStoredArtifact(
536
+ file: File,
537
+ expectedSize: Long,
538
+ expectedSha256: String,
539
+ ): StoredFirmwareArtifact {
540
+ require(file.isFile && file.length() == expectedSize) {
541
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch"
542
+ }
543
+ val sha256 = hashFile(file)
544
+ require(sha256 == expectedSha256) {
545
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch"
546
+ }
547
+ return StoredFirmwareArtifact("fw:$sha256", expectedSize, sha256, file)
548
+ }
549
+
550
+ private fun resolveArtifactFile(artifactRef: String): File {
551
+ require(artifactRefPattern.matches(artifactRef)) {
552
+ "Invalid firmware artifactRef"
553
+ }
554
+ val file = artifactFile(artifactRef.removePrefix("fw:"))
555
+ require(file.isFile) { "Firmware artifact not found" }
556
+ return file
557
+ }
558
+
559
+ private fun artifactFile(sha256: String): File = File(root, "$sha256.bin")
560
+
561
+ fun createLease(transactionId: String): String {
562
+ require(identifierPattern.matches(transactionId)) {
563
+ "Invalid firmware transactionId"
564
+ }
565
+ return synchronized(leaseLock) {
566
+ val leases = loadLeasesLocked()
567
+ require(leases.size < 32) {
568
+ "Too many firmware artifact leases"
569
+ }
570
+ val leaseRef = "fwlease:${UUID.randomUUID()}"
571
+ leases[leaseRef] = LeaseState(transactionId, mutableSetOf())
572
+ saveLeasesLocked(leases)
573
+ leaseRef
574
+ }
575
+ }
576
+
577
+ fun retain(leaseRef: String, artifactRef: String) {
578
+ resolveArtifactFile(artifactRef)
579
+ retainExpectedArtifact(
580
+ leaseRef = leaseRef,
581
+ transactionId = null,
582
+ artifactRef = artifactRef,
583
+ )
584
+ }
585
+
586
+ fun releaseLease(leaseRef: String, disposition: String) {
587
+ require(
588
+ disposition == "completed" ||
589
+ disposition == "safeCancelled" ||
590
+ disposition == "safeAbandoned"
591
+ ) {
592
+ "Invalid firmware lease disposition"
593
+ }
594
+ val transactionId = synchronized(leaseLock) {
595
+ val leases = loadLeasesLocked()
596
+ val removed = leases.remove(validateLeaseRef(leaseRef))
597
+ require(removed != null) {
598
+ "Firmware artifact lease is unavailable"
599
+ }
600
+ saveLeasesLocked(leases)
601
+ removed.transactionId
602
+ }
603
+ cancelledTransactions.remove(transactionId)
604
+ downloadLocks.keys.removeIf { it.transactionId == transactionId }
605
+ }
606
+
607
+ fun reconcileLeases(activeLeaseRefs: Array<String>) {
608
+ require(activeLeaseRefs.size <= 32) {
609
+ "Too many active firmware artifact leases"
610
+ }
611
+ val active = activeLeaseRefs.mapTo(mutableSetOf()) {
612
+ validateLeaseRef(it)
613
+ }
614
+ require(active.size == activeLeaseRefs.size) {
615
+ "Duplicate active firmware artifact lease"
616
+ }
617
+ synchronized(leaseLock) {
618
+ val leases = loadLeasesLocked()
619
+ require(active.all { leases.containsKey(it) }) {
620
+ "Firmware artifact lease reconciliation is incomplete"
621
+ }
622
+ leases.keys.retainAll(active)
623
+ saveLeasesLocked(leases)
624
+ }
625
+ }
626
+
627
+ fun sweepOrphans(): Pair<Int, Long> {
628
+ val retainedSha256 = synchronized(leaseLock) {
629
+ loadLeasesLocked().values
630
+ .flatMap { it.artifactRefs }
631
+ .mapTo(mutableSetOf()) { it.removePrefix("fw:") }
632
+ }
633
+ val activeSha256 = synchronized(activeDownloadLock) {
634
+ activeDownloadCounts.filterValues { it > 0 }.keys.toSet()
635
+ }
636
+ val openFiles = synchronized(readerLock) {
637
+ readers.values.mapTo(mutableSetOf()) { it.file.absolutePath }
638
+ }
639
+ val now = System.currentTimeMillis()
640
+ var deletedFiles = 0
641
+ var deletedBytes = 0L
642
+ root.listFiles()?.forEach { file ->
643
+ if (!file.isFile || file.name == "leases.json") return@forEach
644
+ val sha256 = file.name.take(64)
645
+ if (
646
+ !sha256Pattern.matches(sha256) ||
647
+ sha256 in retainedSha256 ||
648
+ sha256 in activeSha256 ||
649
+ file.absolutePath in openFiles
650
+ ) {
651
+ return@forEach
652
+ }
653
+ val grace = if (file.name.endsWith(".bin")) {
654
+ FINAL_ARTIFACT_GRACE_MS
655
+ } else if (file.name.endsWith(".partial")) {
656
+ PARTIAL_ARTIFACT_GRACE_MS
657
+ } else {
658
+ return@forEach
659
+ }
660
+ if (now - file.lastModified() < grace) return@forEach
661
+ val size = file.length()
662
+ if (file.delete()) {
663
+ deletedFiles += 1
664
+ deletedBytes += size
665
+ }
666
+ }
667
+ return deletedFiles to deletedBytes
668
+ }
669
+
670
+ private fun requireLease(leaseRef: String) {
671
+ synchronized(leaseLock) {
672
+ require(loadLeasesLocked().containsKey(validateLeaseRef(leaseRef))) {
673
+ "Firmware artifact lease is unavailable"
674
+ }
675
+ }
676
+ }
677
+
678
+ private fun retainExpectedArtifact(
679
+ leaseRef: String,
680
+ transactionId: String?,
681
+ artifactRef: String,
682
+ ) {
683
+ require(artifactRefPattern.matches(artifactRef)) {
684
+ "Invalid firmware artifactRef"
685
+ }
686
+ synchronized(leaseLock) {
687
+ val leases = loadLeasesLocked()
688
+ val lease = leases[validateLeaseRef(leaseRef)]
689
+ ?: error("Firmware artifact lease is unavailable")
690
+ if (transactionId != null) {
691
+ require(lease.transactionId == transactionId) {
692
+ "Firmware artifact lease transaction mismatch"
693
+ }
694
+ }
695
+ if (lease.artifactRefs.add(artifactRef)) {
696
+ saveLeasesLocked(leases)
697
+ }
698
+ }
699
+ }
700
+
701
+ private fun validateLeaseRef(leaseRef: String): String {
702
+ require(leaseRefPattern.matches(leaseRef)) {
703
+ "Invalid firmware leaseRef"
704
+ }
705
+ return leaseRef
706
+ }
707
+
708
+ private fun loadLeasesLocked(): MutableMap<String, LeaseState> {
709
+ val file = File(root, "leases.json")
710
+ if (!file.exists()) return mutableMapOf()
711
+ require(file.length() in 1..MAX_LEASE_METADATA_BYTES) {
712
+ "Firmware lease metadata is too large"
713
+ }
714
+ val envelope = JSONObject(file.readText(Charsets.UTF_8))
715
+ require(envelope.optInt("schemaVersion") == 1) {
716
+ "Unsupported firmware lease schema"
717
+ }
718
+ val result = mutableMapOf<String, LeaseState>()
719
+ val jsonLeases = envelope.getJSONObject("leases")
720
+ val keys = jsonLeases.keys()
721
+ while (keys.hasNext()) {
722
+ val leaseRef = validateLeaseRef(keys.next())
723
+ val jsonLease = jsonLeases.getJSONObject(leaseRef)
724
+ val transactionId = jsonLease.getString("transactionId")
725
+ require(identifierPattern.matches(transactionId)) {
726
+ "Invalid persisted firmware transactionId"
727
+ }
728
+ val jsonRefs = jsonLease.getJSONArray("artifactRefs")
729
+ require(jsonRefs.length() <= 4096) {
730
+ "Too many persisted firmware artifact refs"
731
+ }
732
+ val refs = mutableSetOf<String>()
733
+ for (index in 0 until jsonRefs.length()) {
734
+ val artifactRef = jsonRefs.getString(index)
735
+ require(artifactRefPattern.matches(artifactRef) && refs.add(artifactRef)) {
736
+ "Invalid persisted firmware artifact ref"
737
+ }
738
+ }
739
+ result[leaseRef] = LeaseState(transactionId, refs)
740
+ }
741
+ require(result.size <= 32) {
742
+ "Too many persisted firmware artifact leases"
743
+ }
744
+ require(result.values.sumOf { it.artifactRefs.size } <= MAX_TOTAL_LEASE_REFS) {
745
+ "Too many persisted firmware artifact refs"
746
+ }
747
+ return result
748
+ }
749
+
750
+ private fun saveLeasesLocked(leases: Map<String, LeaseState>) {
751
+ require(
752
+ leases.size <= 32 &&
753
+ leases.values.sumOf { it.artifactRefs.size } <= MAX_TOTAL_LEASE_REFS
754
+ ) {
755
+ "Firmware lease metadata is too large"
756
+ }
757
+ val jsonLeases = JSONObject()
758
+ leases.toSortedMap().forEach { (leaseRef, lease) ->
759
+ jsonLeases.put(
760
+ leaseRef,
761
+ JSONObject()
762
+ .put("transactionId", lease.transactionId)
763
+ .put("artifactRefs", JSONArray(lease.artifactRefs.sorted())),
764
+ )
765
+ }
766
+ val bytes = JSONObject()
767
+ .put("schemaVersion", 1)
768
+ .put("leases", jsonLeases)
769
+ .toString()
770
+ .toByteArray(Charsets.UTF_8)
771
+ require(bytes.size.toLong() <= MAX_LEASE_METADATA_BYTES) {
772
+ "Firmware lease metadata is too large"
773
+ }
774
+ val destination = File(root, "leases.json")
775
+ val temporary = File(root, ".leases-${UUID.randomUUID()}.tmp")
776
+ FileOutputStream(temporary).use { output ->
777
+ output.write(bytes)
778
+ output.fd.sync()
779
+ }
780
+ Os.rename(temporary.absolutePath, destination.absolutePath)
781
+ }
782
+
783
+ private fun markDownloadActive(sha256: String, delta: Int) {
784
+ synchronized(activeDownloadLock) {
785
+ val count = (activeDownloadCounts[sha256] ?: 0) + delta
786
+ if (count <= 0) {
787
+ activeDownloadCounts.remove(sha256)
788
+ } else {
789
+ activeDownloadCounts[sha256] = count
790
+ }
791
+ }
792
+ }
793
+
794
+ private fun hashFile(file: File): String {
795
+ val digest = MessageDigest.getInstance("SHA-256")
796
+ BufferedInputStream(FileInputStream(file)).use { input ->
797
+ val buffer = ByteArray(MAX_READ_BYTES)
798
+ while (true) {
799
+ val count = input.read(buffer)
800
+ if (count < 0) break
801
+ if (count > 0) digest.update(buffer, 0, count)
802
+ }
803
+ }
804
+ return digest.digest().toHex()
805
+ }
806
+
807
+ private fun promoteAtomically(source: File, destination: File) {
808
+ destination.parentFile?.mkdirs()
809
+ Os.rename(source.absolutePath, destination.absolutePath)
810
+ }
811
+
812
+ private fun Double.toExactPositiveLong(label: String): Long {
813
+ require(isFinite() && this > 0 && this <= Long.MAX_VALUE.toDouble()) {
814
+ "Invalid firmware $label"
815
+ }
816
+ val converted = toLong()
817
+ require(converted.toDouble() == this) { "Invalid firmware $label" }
818
+ return converted
819
+ }
820
+
821
+ private fun ByteArray.toHex(): String = joinToString("") {
822
+ "%02x".format(it)
823
+ }
824
+
825
+ private fun registerCall(transactionId: String, call: Call) {
826
+ val calls = activeCalls.computeIfAbsent(transactionId) {
827
+ ConcurrentHashMap.newKeySet()
828
+ }
829
+ calls.add(call)
830
+ if (cancelledTransactions.contains(transactionId)) {
831
+ call.cancel()
832
+ }
833
+ }
834
+
835
+ private fun unregisterCall(transactionId: String, call: Call) {
836
+ val calls = activeCalls[transactionId] ?: return
837
+ calls.remove(call)
838
+ if (calls.isEmpty()) {
839
+ activeCalls.remove(transactionId, calls)
840
+ }
841
+ }
842
+ }