@onekeyfe/react-native-range-downloader 3.0.81-alpha.1 → 3.0.81-alpha.11

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 (43) hide show
  1. package/ReactNativeRangeDownloader.podspec +1 -2
  2. package/android/build.gradle +0 -1
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt +12 -5
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweep.kt +111 -0
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactStore.kt +194 -214
  6. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +21 -21
  7. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt +39 -0
  8. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDeadlineTest.kt +26 -0
  9. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt +107 -0
  10. package/ios/FirmwareArtifactStore.swift +667 -435
  11. package/ios/RangeDownloadLogic.swift +317 -0
  12. package/ios/ReactNativeRangeDownloader.swift +60 -484
  13. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +4 -7
  14. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  15. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeParams.hpp +7 -6
  16. package/nitrogen/generated/android/c++/JFirmwareArtifactDownloadParams.hpp +7 -7
  17. package/nitrogen/generated/android/c++/JFirmwareArtifactReceipt.hpp +7 -3
  18. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +0 -19
  19. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +0 -1
  20. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeParams.kt +2 -2
  21. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDownloadParams.kt +3 -3
  22. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactReceipt.kt +6 -3
  23. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +0 -4
  24. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +15 -0
  25. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +0 -3
  26. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +0 -11
  27. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeParams.swift +32 -13
  28. package/nitrogen/generated/ios/swift/FirmwareArtifactDownloadParams.swift +39 -8
  29. package/nitrogen/generated/ios/swift/FirmwareArtifactReceipt.swift +13 -2
  30. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +0 -1
  31. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +0 -19
  32. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeParams.hpp +6 -5
  33. package/nitrogen/generated/shared/c++/FirmwareArtifactDownloadParams.hpp +9 -9
  34. package/nitrogen/generated/shared/c++/FirmwareArtifactReceipt.hpp +6 -2
  35. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +0 -1
  36. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +0 -4
  37. package/package.json +1 -2
  38. package/src/ReactNativeRangeDownloader.nitro.ts +7 -11
  39. package/ios/FirmwareBackgroundSessionEventRouter.swift +0 -17
  40. package/nitrogen/generated/android/c++/JFirmwareArtifactLeaseReconcileParams.hpp +0 -76
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactLeaseReconcileParams.kt +0 -38
  42. package/nitrogen/generated/ios/swift/FirmwareArtifactLeaseReconcileParams.swift +0 -48
  43. package/nitrogen/generated/shared/c++/FirmwareArtifactLeaseReconcileParams.hpp +0 -76
@@ -2,11 +2,9 @@ package com.margelo.nitro.reactnativerangedownloader
2
2
 
3
3
  import android.system.Os
4
4
  import com.margelo.nitro.NitroModules
5
- import com.sniconnect.SniPinnedTransport
6
5
  import java.io.BufferedInputStream
7
6
  import java.io.File
8
7
  import java.io.FileInputStream
9
- import java.io.FileOutputStream
10
8
  import java.io.IOException
11
9
  import java.io.RandomAccessFile
12
10
  import java.security.MessageDigest
@@ -14,14 +12,29 @@ import java.util.UUID
14
12
  import java.util.concurrent.ConcurrentHashMap
15
13
  import java.util.concurrent.TimeUnit
16
14
  import java.util.zip.ZipInputStream
15
+ import javax.net.ssl.SSLException
16
+ import kotlin.math.ceil
17
17
  import okhttp3.Call
18
18
  import okhttp3.OkHttpClient
19
19
  import okhttp3.Protocol
20
20
  import okhttp3.Request
21
21
  import okhttp3.Response
22
22
  import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
23
- import org.json.JSONArray
24
- import org.json.JSONObject
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
+ }
25
38
 
26
39
  internal data class StoredFirmwareArtifact(
27
40
  val artifactRef: String,
@@ -44,22 +57,25 @@ private data class StagedFirmwareArchiveEntry(
44
57
 
45
58
  private data class FirmwareDownloadKey(
46
59
  val transactionId: String,
47
- val expectedSha256: String,
60
+ val taskId: String,
61
+ val downloadToken: String,
48
62
  )
49
63
 
64
+ private class FirmwareDownloadLock {
65
+ val monitor = Any()
66
+ var references = 0
67
+ }
68
+
50
69
  internal object FirmwareArtifactStore {
51
70
  const val MAX_READ_BYTES = 256 * 1024
52
71
 
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
72
+ const val MAX_ARTIFACT_BYTES = 512L * 1024 * 1024
58
73
  private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$")
59
74
  private val artifactRefPattern = Regex("^fw:[a-f0-9]{64}$")
60
75
  private val leaseRefPattern = Regex("^fwlease:[a-f0-9-]{36}$")
61
76
  private val identifierPattern = Regex("^[A-Za-z0-9._:-]{1,160}$")
62
- private val downloadLocks = ConcurrentHashMap<FirmwareDownloadKey, Any>()
77
+ private val downloadLocks =
78
+ ConcurrentHashMap<FirmwareDownloadKey, FirmwareDownloadLock>()
63
79
  private val activeCalls =
64
80
  ConcurrentHashMap<String, MutableSet<Call>>()
65
81
  private val cancelledTransactions =
@@ -69,6 +85,7 @@ internal object FirmwareArtifactStore {
69
85
  private val leaseLock = Any()
70
86
  private val readerLock = Any()
71
87
  private val readers = mutableMapOf<String, OpenReader>()
88
+ private val leases = mutableMapOf<String, LeaseState>()
72
89
 
73
90
  private data class LeaseState(
74
91
  val transactionId: String,
@@ -97,26 +114,47 @@ internal object FirmwareArtifactStore {
97
114
  check(!cancelledTransactions.contains(params.transactionId)) {
98
115
  "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
99
116
  }
100
- retainExpectedArtifact(
101
- leaseRef = params.leaseRef,
102
- transactionId = params.transactionId,
103
- artifactRef = "fw:${validated.expectedSha256}",
104
- )
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)
105
124
  val lockKey = FirmwareDownloadKey(
106
125
  params.transactionId,
107
- validated.expectedSha256,
126
+ params.taskId,
127
+ validated.downloadToken,
108
128
  )
109
- val lock = downloadLocks.computeIfAbsent(lockKey) { Any() }
110
- markDownloadActive(validated.expectedSha256, 1)
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)
111
135
  try {
112
- return synchronized(lock) {
136
+ val artifact = synchronized(downloadLock.monitor) {
113
137
  check(!cancelledTransactions.contains(params.transactionId)) {
114
138
  "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
115
139
  }
116
140
  downloadLocked(params, validated)
117
141
  }
142
+ retainExpectedArtifact(
143
+ leaseRef = params.leaseRef,
144
+ transactionId = params.transactionId,
145
+ artifactRef = artifact.artifactRef,
146
+ )
147
+ return artifact
118
148
  } finally {
119
- markDownloadActive(validated.expectedSha256, -1)
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
+ }
120
158
  }
121
159
  }
122
160
 
@@ -133,7 +171,7 @@ internal object FirmwareArtifactStore {
133
171
  fun discard(artifactRef: String) {
134
172
  val file = resolveArtifactFile(artifactRef)
135
173
  synchronized(leaseLock) {
136
- require(loadLeasesLocked().values.none { artifactRef in it.artifactRefs }) {
174
+ require(leases.values.none { artifactRef in it.artifactRefs }) {
137
175
  "ARTIFACT_LEASED: firmware artifact is retained"
138
176
  }
139
177
  }
@@ -186,16 +224,19 @@ internal object FirmwareArtifactStore {
186
224
  fun materializeArchive(
187
225
  leaseRef: String,
188
226
  artifactRef: String,
189
- expectedEntries: Array<FirmwareArchiveExpectedEntry>,
227
+ expectedEntries: Array<FirmwareArchiveExpectedEntry>?,
190
228
  ): List<StoredFirmwareArchiveEntry> {
191
229
  requireLease(leaseRef)
192
230
  val archiveFile = resolveArtifactFile(artifactRef)
193
- val requirements = FirmwareArchiveRules.validateRequirements(expectedEntries)
231
+ val requirements = expectedEntries?.let {
232
+ FirmwareArchiveRules.validateRequirements(it)
233
+ }
194
234
  val centralEntries = FirmwareArchiveRules.validateCentralDirectory(
195
235
  archiveFile,
196
236
  requirements,
197
237
  )
198
- val requirementsByName = requirements.associateBy { it.entryName }
238
+ val requirementsByName = requirements?.associateBy { it.entryName }.orEmpty()
239
+ val centralEntriesByName = centralEntries.associateBy { it.name }
199
240
  val centralNames = centralEntries.mapTo(mutableSetOf()) { it.name }
200
241
  val scratchDir = File(root, "archive-${UUID.randomUUID()}")
201
242
  check(scratchDir.mkdirs()) { "Firmware archive scratch directory cannot be created" }
@@ -208,16 +249,18 @@ internal object FirmwareArtifactStore {
208
249
  require(!zipEntry.isDirectory) {
209
250
  "Firmware archive contains an unexpected directory"
210
251
  }
211
- val requirement = requirementsByName[zipEntry.name]
252
+ val centralEntry = centralEntriesByName[zipEntry.name]
212
253
  ?: error("Firmware archive contains an unexpected entry")
254
+ val requirement = requirementsByName[zipEntry.name]
213
255
  require(
214
256
  centralNames.contains(zipEntry.name) &&
215
257
  entryNames.add(zipEntry.name)
216
258
  ) {
217
259
  "Firmware archive contains a duplicate or mismatched entry"
218
260
  }
219
- val expectedSize = requirement.expectedSize.toLong()
220
- val expectedSha256 = requirement.expectedSha256.lowercase()
261
+ val expectedSize = requirement?.expectedSize?.toLong()
262
+ ?: centralEntry.uncompressedSize
263
+ val expectedSha256 = requirement?.expectedSha256?.lowercase()
221
264
  val scratchFile = File(scratchDir, "${staged.size}.entry")
222
265
  val digest = MessageDigest.getInstance("SHA-256")
223
266
  var entrySize = 0L
@@ -237,11 +280,14 @@ internal object FirmwareArtifactStore {
237
280
  output.fd.sync()
238
281
  }
239
282
  val sha256 = digest.digest().toHex()
240
- require(entrySize == expectedSize && sha256 == expectedSha256) {
283
+ require(
284
+ entrySize == expectedSize &&
285
+ (expectedSha256 == null || sha256 == expectedSha256)
286
+ ) {
241
287
  "Firmware archive entry integrity mismatch"
242
288
  }
243
289
  staged += StagedFirmwareArchiveEntry(
244
- entryName = requirement.entryName,
290
+ entryName = zipEntry.name,
245
291
  size = entrySize,
246
292
  sha256 = sha256,
247
293
  file = scratchFile,
@@ -280,10 +326,11 @@ internal object FirmwareArtifactStore {
280
326
  }
281
327
 
282
328
  private data class ValidatedDownload(
283
- val expectedSize: Long,
329
+ val expectedSize: Long?,
284
330
  val maxBytes: Long,
285
- val expectedSha256: String,
286
- val hostname: String,
331
+ val expectedSha256: String?,
332
+ val downloadToken: String,
333
+ val overallDeadlineSeconds: Double,
287
334
  )
288
335
 
289
336
  private fun validateDownloadParams(
@@ -316,31 +363,32 @@ internal object FirmwareArtifactStore {
316
363
  ) {
317
364
  "Firmware URL must use HTTPS port 443"
318
365
  }
319
- val expectedSize = params.expectedSize.toExactPositiveLong("expectedSize")
366
+ val expectedSize = params.expectedSize?.toExactPositiveLong("expectedSize")
320
367
  val maxBytes = params.maxBytes.toExactPositiveLong("maxBytes")
321
- require(maxBytes == expectedSize && maxBytes <= MAX_ARTIFACT_BYTES) {
368
+ require(
369
+ maxBytes <= MAX_ARTIFACT_BYTES &&
370
+ (expectedSize == null || expectedSize <= maxBytes)
371
+ ) {
322
372
  "Invalid firmware maxBytes"
323
373
  }
324
- require(sha256Pattern.matches(params.expectedSha256)) {
374
+ val expectedSha256 = params.expectedSha256?.lowercase()
375
+ require(expectedSha256 == null || sha256Pattern.matches(expectedSha256)) {
325
376
  "Invalid firmware artifact SHA-256"
326
377
  }
327
- require(params.routeType == "domain" || params.routeType == "pinnedIp") {
378
+ val overallDeadlineSeconds =
379
+ validateFirmwareDownloadDeadlineSeconds(params.overallDeadlineSeconds)
380
+ require(params.routeType == "domain") {
328
381
  "Invalid firmware route type"
329
382
  }
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
- }
383
+ require(params.resolvedIp == null) {
384
+ "Domain route must not include resolvedIp"
338
385
  }
339
386
  return ValidatedDownload(
340
387
  expectedSize = expectedSize,
341
388
  maxBytes = maxBytes,
342
- expectedSha256 = params.expectedSha256.lowercase(),
343
- hostname = url.host,
389
+ expectedSha256 = expectedSha256,
390
+ downloadToken = expectedSha256 ?: sha256(params.url),
391
+ overallDeadlineSeconds = overallDeadlineSeconds,
344
392
  )
345
393
  }
346
394
 
@@ -348,26 +396,37 @@ internal object FirmwareArtifactStore {
348
396
  params: FirmwareArtifactDownloadParams,
349
397
  validated: ValidatedDownload,
350
398
  ): StoredFirmwareArtifact {
351
- val finalFile = artifactFile(validated.expectedSha256)
352
- validateStoredArtifactOrNull(
353
- finalFile,
354
- validated.expectedSize,
355
- validated.expectedSha256,
356
- )?.let { return it }
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
+ }
357
408
 
409
+ val transactionToken = sha256(params.transactionId).take(16)
358
410
  val partialFile = File(
359
411
  root,
360
- "${validated.expectedSha256}.${params.taskId}.partial",
412
+ "${validated.downloadToken}.${params.taskId}.$transactionToken.partial",
361
413
  )
362
- if (partialFile.length() > validated.expectedSize) {
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) {
363
417
  check(partialFile.delete()) { "Invalid firmware partial cannot be removed" }
364
418
  }
365
- if (partialFile.length() == validated.expectedSize) {
366
- validateStoredArtifactOrNull(
419
+ if (
420
+ validated.expectedSize != null &&
421
+ partialFile.length() == validated.expectedSize
422
+ ) {
423
+ validateDownloadedArtifactOrNull(
367
424
  partialFile,
368
425
  validated.expectedSize,
369
426
  validated.expectedSha256,
427
+ validated.maxBytes,
370
428
  )?.let {
429
+ val finalFile = artifactFile(it.sha256)
371
430
  promoteAtomically(partialFile, finalFile)
372
431
  return StoredFirmwareArtifact(
373
432
  it.artifactRef,
@@ -387,25 +446,16 @@ internal object FirmwareArtifactStore {
387
446
  requestBuilder.header("Range", "bytes=$resumeOffset-")
388
447
  }
389
448
 
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
- }
449
+ val client = OkHttpClient.Builder()
450
+ .protocols(listOf(Protocol.HTTP_1_1))
451
+ .followRedirects(false)
452
+ .followSslRedirects(false)
453
+ .build()
402
454
  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
- }
455
+ call.timeout().timeout(
456
+ ceil(validated.overallDeadlineSeconds * 1000).toLong(),
457
+ TimeUnit.MILLISECONDS,
458
+ )
409
459
 
410
460
  registerCall(params.transactionId, call)
411
461
  try {
@@ -428,6 +478,12 @@ internal object FirmwareArtifactStore {
428
478
  error,
429
479
  )
430
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
+ }
431
487
  throw IllegalStateException(
432
488
  "ARTIFACT_NETWORK_FAILED: firmware request failed",
433
489
  error,
@@ -440,15 +496,17 @@ internal object FirmwareArtifactStore {
440
496
  }
441
497
 
442
498
  val artifact = try {
443
- validateStoredArtifact(
499
+ validateDownloadedArtifact(
444
500
  partialFile,
445
501
  validated.expectedSize,
446
502
  validated.expectedSha256,
503
+ validated.maxBytes,
447
504
  )
448
505
  } catch (error: Throwable) {
449
506
  partialFile.delete()
450
507
  throw error
451
508
  }
509
+ val finalFile = artifactFile(artifact.sha256)
452
510
  promoteAtomically(partialFile, finalFile)
453
511
  return StoredFirmwareArtifact(
454
512
  artifact.artifactRef,
@@ -462,7 +520,7 @@ internal object FirmwareArtifactStore {
462
520
  response: Response,
463
521
  partialFile: File,
464
522
  resumeOffset: Long,
465
- expectedSize: Long,
523
+ expectedSize: Long?,
466
524
  maxBytes: Long,
467
525
  ) {
468
526
  require(response.code == 200 || response.code == 206) {
@@ -475,6 +533,7 @@ internal object FirmwareArtifactStore {
475
533
  response.header("Content-Range"),
476
534
  if (append) resumeOffset else 0,
477
535
  expectedSize,
536
+ maxBytes,
478
537
  )
479
538
  ) {
480
539
  "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid"
@@ -507,7 +566,8 @@ internal object FirmwareArtifactStore {
507
566
  private fun validateContentRange(
508
567
  value: String?,
509
568
  expectedStart: Long,
510
- expectedTotal: Long,
569
+ expectedTotal: Long?,
570
+ maxBytes: Long,
511
571
  ): Boolean {
512
572
  val match = value
513
573
  ?.lowercase()
@@ -519,7 +579,39 @@ internal object FirmwareArtifactStore {
519
579
  return start == expectedStart &&
520
580
  end >= start &&
521
581
  end < total &&
522
- total == expectedTotal
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)
523
615
  }
524
616
 
525
617
  private fun validateStoredArtifactOrNull(
@@ -563,13 +655,11 @@ internal object FirmwareArtifactStore {
563
655
  "Invalid firmware transactionId"
564
656
  }
565
657
  return synchronized(leaseLock) {
566
- val leases = loadLeasesLocked()
567
658
  require(leases.size < 32) {
568
659
  "Too many firmware artifact leases"
569
660
  }
570
661
  val leaseRef = "fwlease:${UUID.randomUUID()}"
571
662
  leases[leaseRef] = LeaseState(transactionId, mutableSetOf())
572
- saveLeasesLocked(leases)
573
663
  leaseRef
574
664
  }
575
665
  }
@@ -592,41 +682,18 @@ internal object FirmwareArtifactStore {
592
682
  "Invalid firmware lease disposition"
593
683
  }
594
684
  val transactionId = synchronized(leaseLock) {
595
- val leases = loadLeasesLocked()
596
685
  val removed = leases.remove(validateLeaseRef(leaseRef))
597
686
  require(removed != null) {
598
687
  "Firmware artifact lease is unavailable"
599
688
  }
600
- saveLeasesLocked(leases)
601
689
  removed.transactionId
602
690
  }
603
691
  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
692
  }
626
693
 
627
694
  fun sweepOrphans(): Pair<Int, Long> {
628
695
  val retainedSha256 = synchronized(leaseLock) {
629
- loadLeasesLocked().values
696
+ leases.values
630
697
  .flatMap { it.artifactRefs }
631
698
  .mapTo(mutableSetOf()) { it.removePrefix("fw:") }
632
699
  }
@@ -636,45 +703,31 @@ internal object FirmwareArtifactStore {
636
703
  val openFiles = synchronized(readerLock) {
637
704
  readers.values.mapTo(mutableSetOf()) { it.file.absolutePath }
638
705
  }
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
706
+ return sweepFirmwareArtifactOrphansAtRoot(
707
+ root = root,
708
+ retainedSha256 = retainedSha256,
709
+ activeSha256 = activeSha256,
710
+ openPaths = openFiles,
711
+ )
668
712
  }
669
713
 
670
714
  private fun requireLease(leaseRef: String) {
671
715
  synchronized(leaseLock) {
672
- require(loadLeasesLocked().containsKey(validateLeaseRef(leaseRef))) {
716
+ require(leases.containsKey(validateLeaseRef(leaseRef))) {
673
717
  "Firmware artifact lease is unavailable"
674
718
  }
675
719
  }
676
720
  }
677
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
+
678
731
  private fun retainExpectedArtifact(
679
732
  leaseRef: String,
680
733
  transactionId: String?,
@@ -684,7 +737,6 @@ internal object FirmwareArtifactStore {
684
737
  "Invalid firmware artifactRef"
685
738
  }
686
739
  synchronized(leaseLock) {
687
- val leases = loadLeasesLocked()
688
740
  val lease = leases[validateLeaseRef(leaseRef)]
689
741
  ?: error("Firmware artifact lease is unavailable")
690
742
  if (transactionId != null) {
@@ -692,9 +744,7 @@ internal object FirmwareArtifactStore {
692
744
  "Firmware artifact lease transaction mismatch"
693
745
  }
694
746
  }
695
- if (lease.artifactRefs.add(artifactRef)) {
696
- saveLeasesLocked(leases)
697
- }
747
+ lease.artifactRefs.add(artifactRef)
698
748
  }
699
749
  }
700
750
 
@@ -705,81 +755,6 @@ internal object FirmwareArtifactStore {
705
755
  return leaseRef
706
756
  }
707
757
 
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
758
  private fun markDownloadActive(sha256: String, delta: Int) {
784
759
  synchronized(activeDownloadLock) {
785
760
  val count = (activeDownloadCounts[sha256] ?: 0) + delta
@@ -804,6 +779,11 @@ internal object FirmwareArtifactStore {
804
779
  return digest.digest().toHex()
805
780
  }
806
781
 
782
+ private fun sha256(value: String): String =
783
+ MessageDigest.getInstance("SHA-256")
784
+ .digest(value.toByteArray(Charsets.UTF_8))
785
+ .toHex()
786
+
807
787
  private fun promoteAtomically(source: File, destination: File) {
808
788
  destination.parentFile?.mkdirs()
809
789
  Os.rename(source.absolutePath, destination.absolutePath)