@onekeyfe/react-native-range-downloader 3.0.81 → 3.0.85

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