@onekeyfe/react-native-range-downloader 3.0.81-alpha.7 → 3.0.81-alpha.9

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 (24) hide show
  1. package/ReactNativeRangeDownloader.podspec +1 -1
  2. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRules.kt +12 -5
  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 +132 -68
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +1 -1
  6. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveRulesTest.kt +39 -0
  7. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactOrphanSweepTest.kt +107 -0
  8. package/ios/FirmwareArtifactStore.swift +242 -172
  9. package/ios/RangeDownloadLogic.swift +194 -36
  10. package/ios/ReactNativeRangeDownloader.swift +9 -423
  11. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +3 -3
  12. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  13. package/nitrogen/generated/android/c++/JFirmwareArchiveMaterializeParams.hpp +7 -6
  14. package/nitrogen/generated/android/c++/JFirmwareArtifactDownloadParams.hpp +7 -7
  15. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArchiveMaterializeParams.kt +2 -2
  16. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/FirmwareArtifactDownloadParams.kt +3 -3
  17. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +15 -0
  18. package/nitrogen/generated/ios/swift/FirmwareArchiveMaterializeParams.swift +32 -13
  19. package/nitrogen/generated/ios/swift/FirmwareArtifactDownloadParams.swift +39 -8
  20. package/nitrogen/generated/shared/c++/FirmwareArchiveMaterializeParams.hpp +6 -5
  21. package/nitrogen/generated/shared/c++/FirmwareArtifactDownloadParams.hpp +9 -9
  22. package/package.json +2 -2
  23. package/src/ReactNativeRangeDownloader.nitro.ts +3 -3
  24. package/ios/FirmwareBackgroundSessionEventRouter.swift +0 -17
@@ -23,7 +23,7 @@ Pod::Spec.new do |s|
23
23
  s.dependency 'React-jsi'
24
24
  s.dependency 'React-callinvoker'
25
25
  s.dependency 'ReactNativeNativeLogger'
26
- s.dependency 'SniConnect', package["version"]
26
+ s.dependency 'SniConnect', package["peerDependencies"]["@onekeyfe/react-native-sni-connect"]
27
27
  s.public_header_files = "ios/FirmwareArchiveMinizipBridge.h"
28
28
  s.pod_target_xcconfig = {
29
29
  'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"',
@@ -78,24 +78,31 @@ internal object FirmwareArchiveRules {
78
78
 
79
79
  fun validateCentralDirectory(
80
80
  file: File,
81
- requirements: List<FirmwareArchiveExpectedEntry>,
81
+ requirements: List<FirmwareArchiveExpectedEntry>?,
82
82
  ): List<FirmwareArchiveCentralEntry> {
83
83
  val entries = scanCentralDirectory(file)
84
- require(entries.size == requirements.size) {
84
+ require(requirements == null || entries.size == requirements.size) {
85
85
  "Firmware archive has missing or extra entries"
86
86
  }
87
- val requirementsByName = requirements.associateBy { it.entryName }
87
+ val requirementsByName = requirements?.associateBy { it.entryName }.orEmpty()
88
88
  val names = mutableSetOf<String>()
89
89
  val canonicalNames = mutableSetOf<String>()
90
+ var totalSize = 0L
90
91
  entries.forEach { entry ->
91
92
  val requirement = requirementsByName[entry.name]
92
- ?: error("Firmware archive contains an unexpected entry")
93
- val expectedSize = requirement.expectedSize.toLong()
93
+ require(requirements == null || requirement != null) {
94
+ "Firmware archive contains an unexpected entry"
95
+ }
96
+ val expectedSize = requirement?.expectedSize?.toLong()
97
+ ?: entry.uncompressedSize
98
+ totalSize = Math.addExact(totalSize, entry.uncompressedSize)
94
99
  require(
95
100
  names.add(entry.name) &&
96
101
  validatePortableName(entry.name, canonicalNames) &&
97
102
  entry.uncompressedSize == expectedSize &&
98
103
  entry.uncompressedSize > 0 &&
104
+ entry.uncompressedSize <= MAX_ARCHIVE_ENTRY_BYTES &&
105
+ totalSize <= MAX_ARCHIVE_EXPANDED_BYTES &&
99
106
  entry.compressedSize in 0..MAX_ARCHIVE_EXPANDED_BYTES &&
100
107
  entry.uncompressedSize <=
101
108
  Math.multiplyExact(entry.compressedSize.coerceAtLeast(1), 1000) &&
@@ -0,0 +1,111 @@
1
+ package com.margelo.nitro.reactnativerangedownloader
2
+
3
+ import java.io.File
4
+
5
+ internal const val FIRMWARE_ARTIFACT_FINAL_GRACE_MS = 24L * 60 * 60 * 1000
6
+ internal const val FIRMWARE_ARTIFACT_PARTIAL_GRACE_MS = 7L * 24 * 60 * 60 * 1000
7
+ internal const val FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS =
8
+ FIRMWARE_ARTIFACT_PARTIAL_GRACE_MS
9
+
10
+ private val firmwareArtifactSha256Pattern = Regex("^[a-f0-9]{64}$")
11
+ private val firmwareArchiveScratchPattern = Regex(
12
+ "^archive-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" +
13
+ "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
14
+ )
15
+ private val firmwarePromoteScratchPattern = Regex(
16
+ "^\\.promote-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" +
17
+ "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
18
+ )
19
+
20
+ private fun File.isSymbolicLinkEntry(): Boolean = try {
21
+ val canonicalParent = parentFile?.canonicalFile
22
+ val entryFromCanonicalParent = canonicalParent?.let { File(it, name) } ?: this
23
+ entryFromCanonicalParent.canonicalFile != entryFromCanonicalParent.absoluteFile
24
+ } catch (_: Exception) {
25
+ true
26
+ }
27
+
28
+ private fun firmwareArtifactEntrySize(entry: File): Long {
29
+ if (entry.isSymbolicLinkEntry()) return 0
30
+ if (entry.isFile) return entry.length()
31
+ if (!entry.isDirectory) return 0
32
+ return entry.listFiles()?.sumOf(::firmwareArtifactEntrySize) ?: 0
33
+ }
34
+
35
+ private fun deleteFirmwareArtifactEntry(entry: File): Boolean {
36
+ if (entry.isSymbolicLinkEntry()) return entry.delete()
37
+ if (entry.isDirectory) {
38
+ val children = entry.listFiles() ?: return false
39
+ if (children.any { !deleteFirmwareArtifactEntry(it) }) return false
40
+ }
41
+ return entry.delete()
42
+ }
43
+
44
+ private fun firmwareArtifactEntryExceededGrace(
45
+ entry: File,
46
+ nowMs: Long,
47
+ graceMs: Long,
48
+ ): Boolean {
49
+ val modifiedAt = entry.lastModified()
50
+ return modifiedAt > 0 && modifiedAt <= nowMs && nowMs - modifiedAt >= graceMs
51
+ }
52
+
53
+ internal fun sweepFirmwareArtifactOrphansAtRoot(
54
+ root: File,
55
+ retainedSha256: Set<String>,
56
+ activeSha256: Set<String>,
57
+ openPaths: Set<String>,
58
+ nowMs: Long = System.currentTimeMillis(),
59
+ ): Pair<Int, Long> {
60
+ var deletedFiles = 0
61
+ var deletedBytes = 0L
62
+ root.listFiles()?.forEach { entry ->
63
+ val isSymbolicLink = entry.isSymbolicLinkEntry()
64
+ val isArchiveScratch = firmwareArchiveScratchPattern.matches(entry.name)
65
+ val isPromoteScratch = firmwarePromoteScratchPattern.matches(entry.name)
66
+ val isScratchCandidate = !isSymbolicLink &&
67
+ ((isArchiveScratch && entry.isDirectory) ||
68
+ (isPromoteScratch && entry.isFile))
69
+ if (isScratchCandidate) {
70
+ if (
71
+ firmwareArtifactEntryExceededGrace(
72
+ entry,
73
+ nowMs,
74
+ FIRMWARE_ARTIFACT_SCRATCH_GRACE_MS,
75
+ )
76
+ ) {
77
+ val size = firmwareArtifactEntrySize(entry)
78
+ if (deleteFirmwareArtifactEntry(entry)) {
79
+ deletedFiles += 1
80
+ deletedBytes += size
81
+ }
82
+ }
83
+ return@forEach
84
+ }
85
+
86
+ if (!entry.isFile || isSymbolicLink || entry.name.length < 64) return@forEach
87
+ val sha256 = entry.name.take(64)
88
+ if (
89
+ !firmwareArtifactSha256Pattern.matches(sha256) ||
90
+ sha256 in retainedSha256 ||
91
+ sha256 in activeSha256 ||
92
+ entry.absolutePath in openPaths
93
+ ) {
94
+ return@forEach
95
+ }
96
+ val grace = if (entry.name.endsWith(".bin")) {
97
+ FIRMWARE_ARTIFACT_FINAL_GRACE_MS
98
+ } else if (entry.name.endsWith(".partial")) {
99
+ FIRMWARE_ARTIFACT_PARTIAL_GRACE_MS
100
+ } else {
101
+ return@forEach
102
+ }
103
+ if (!firmwareArtifactEntryExceededGrace(entry, nowMs, grace)) return@forEach
104
+ val size = entry.length()
105
+ if (entry.delete()) {
106
+ deletedFiles += 1
107
+ deletedBytes += size
108
+ }
109
+ }
110
+ return deletedFiles to deletedBytes
111
+ }
@@ -57,7 +57,9 @@ private data class StagedFirmwareArchiveEntry(
57
57
  )
58
58
 
59
59
  private data class FirmwareDownloadKey(
60
- val expectedSha256: String,
60
+ val transactionId: String,
61
+ val taskId: String,
62
+ val downloadToken: String,
61
63
  )
62
64
 
63
65
  private class FirmwareDownloadLock {
@@ -69,8 +71,6 @@ internal object FirmwareArtifactStore {
69
71
  const val MAX_READ_BYTES = 256 * 1024
70
72
 
71
73
  private const val MAX_ARTIFACT_BYTES = 512L * 1024 * 1024
72
- private const val FINAL_ARTIFACT_GRACE_MS = 24L * 60 * 60 * 1000
73
- private const val PARTIAL_ARTIFACT_GRACE_MS = 7L * 24 * 60 * 60 * 1000
74
74
  private val sha256Pattern = Regex("^[a-fA-F0-9]{64}$")
75
75
  private val artifactRefPattern = Regex("^fw:[a-f0-9]{64}$")
76
76
  private val leaseRefPattern = Regex("^fwlease:[a-f0-9-]{36}$")
@@ -115,27 +115,39 @@ internal object FirmwareArtifactStore {
115
115
  check(!cancelledTransactions.contains(params.transactionId)) {
116
116
  "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
117
117
  }
118
- retainExpectedArtifact(
119
- leaseRef = params.leaseRef,
120
- transactionId = params.transactionId,
121
- artifactRef = "fw:${validated.expectedSha256}",
118
+ validated.expectedSha256?.let {
119
+ retainExpectedArtifact(
120
+ leaseRef = params.leaseRef,
121
+ transactionId = params.transactionId,
122
+ artifactRef = "fw:$it",
123
+ )
124
+ } ?: requireLeaseTransaction(params.leaseRef, params.transactionId)
125
+ val lockKey = FirmwareDownloadKey(
126
+ params.transactionId,
127
+ params.taskId,
128
+ validated.downloadToken,
122
129
  )
123
- val lockKey = FirmwareDownloadKey(validated.expectedSha256)
124
130
  val downloadLock = downloadLocks.compute(lockKey) { _, current ->
125
131
  (current ?: FirmwareDownloadLock()).also {
126
132
  it.references += 1
127
133
  }
128
134
  } ?: error("Firmware artifact lock is unavailable")
129
- markDownloadActive(validated.expectedSha256, 1)
135
+ markDownloadActive(validated.downloadToken, 1)
130
136
  try {
131
- return synchronized(downloadLock.monitor) {
137
+ val artifact = synchronized(downloadLock.monitor) {
132
138
  check(!cancelledTransactions.contains(params.transactionId)) {
133
139
  "ARTIFACT_CANCELLED: firmware artifact download was cancelled"
134
140
  }
135
141
  downloadLocked(params, validated)
136
142
  }
143
+ retainExpectedArtifact(
144
+ leaseRef = params.leaseRef,
145
+ transactionId = params.transactionId,
146
+ artifactRef = artifact.artifactRef,
147
+ )
148
+ return artifact
137
149
  } finally {
138
- markDownloadActive(validated.expectedSha256, -1)
150
+ markDownloadActive(validated.downloadToken, -1)
139
151
  downloadLocks.compute(lockKey) { _, current ->
140
152
  if (current !== downloadLock) {
141
153
  current
@@ -213,16 +225,19 @@ internal object FirmwareArtifactStore {
213
225
  fun materializeArchive(
214
226
  leaseRef: String,
215
227
  artifactRef: String,
216
- expectedEntries: Array<FirmwareArchiveExpectedEntry>,
228
+ expectedEntries: Array<FirmwareArchiveExpectedEntry>?,
217
229
  ): List<StoredFirmwareArchiveEntry> {
218
230
  requireLease(leaseRef)
219
231
  val archiveFile = resolveArtifactFile(artifactRef)
220
- val requirements = FirmwareArchiveRules.validateRequirements(expectedEntries)
232
+ val requirements = expectedEntries?.let {
233
+ FirmwareArchiveRules.validateRequirements(it)
234
+ }
221
235
  val centralEntries = FirmwareArchiveRules.validateCentralDirectory(
222
236
  archiveFile,
223
237
  requirements,
224
238
  )
225
- val requirementsByName = requirements.associateBy { it.entryName }
239
+ val requirementsByName = requirements?.associateBy { it.entryName }.orEmpty()
240
+ val centralEntriesByName = centralEntries.associateBy { it.name }
226
241
  val centralNames = centralEntries.mapTo(mutableSetOf()) { it.name }
227
242
  val scratchDir = File(root, "archive-${UUID.randomUUID()}")
228
243
  check(scratchDir.mkdirs()) { "Firmware archive scratch directory cannot be created" }
@@ -235,16 +250,18 @@ internal object FirmwareArtifactStore {
235
250
  require(!zipEntry.isDirectory) {
236
251
  "Firmware archive contains an unexpected directory"
237
252
  }
238
- val requirement = requirementsByName[zipEntry.name]
253
+ val centralEntry = centralEntriesByName[zipEntry.name]
239
254
  ?: error("Firmware archive contains an unexpected entry")
255
+ val requirement = requirementsByName[zipEntry.name]
240
256
  require(
241
257
  centralNames.contains(zipEntry.name) &&
242
258
  entryNames.add(zipEntry.name)
243
259
  ) {
244
260
  "Firmware archive contains a duplicate or mismatched entry"
245
261
  }
246
- val expectedSize = requirement.expectedSize.toLong()
247
- val expectedSha256 = requirement.expectedSha256.lowercase()
262
+ val expectedSize = requirement?.expectedSize?.toLong()
263
+ ?: centralEntry.uncompressedSize
264
+ val expectedSha256 = requirement?.expectedSha256?.lowercase()
248
265
  val scratchFile = File(scratchDir, "${staged.size}.entry")
249
266
  val digest = MessageDigest.getInstance("SHA-256")
250
267
  var entrySize = 0L
@@ -264,11 +281,14 @@ internal object FirmwareArtifactStore {
264
281
  output.fd.sync()
265
282
  }
266
283
  val sha256 = digest.digest().toHex()
267
- require(entrySize == expectedSize && sha256 == expectedSha256) {
284
+ require(
285
+ entrySize == expectedSize &&
286
+ (expectedSha256 == null || sha256 == expectedSha256)
287
+ ) {
268
288
  "Firmware archive entry integrity mismatch"
269
289
  }
270
290
  staged += StagedFirmwareArchiveEntry(
271
- entryName = requirement.entryName,
291
+ entryName = zipEntry.name,
272
292
  size = entrySize,
273
293
  sha256 = sha256,
274
294
  file = scratchFile,
@@ -307,9 +327,10 @@ internal object FirmwareArtifactStore {
307
327
  }
308
328
 
309
329
  private data class ValidatedDownload(
310
- val expectedSize: Long,
330
+ val expectedSize: Long?,
311
331
  val maxBytes: Long,
312
- val expectedSha256: String,
332
+ val expectedSha256: String?,
333
+ val downloadToken: String,
313
334
  val hostname: String,
314
335
  val overallDeadlineSeconds: Double,
315
336
  )
@@ -344,12 +365,16 @@ internal object FirmwareArtifactStore {
344
365
  ) {
345
366
  "Firmware URL must use HTTPS port 443"
346
367
  }
347
- val expectedSize = params.expectedSize.toExactPositiveLong("expectedSize")
368
+ val expectedSize = params.expectedSize?.toExactPositiveLong("expectedSize")
348
369
  val maxBytes = params.maxBytes.toExactPositiveLong("maxBytes")
349
- require(maxBytes == expectedSize && maxBytes <= MAX_ARTIFACT_BYTES) {
370
+ require(
371
+ maxBytes <= MAX_ARTIFACT_BYTES &&
372
+ (expectedSize == null || expectedSize <= maxBytes)
373
+ ) {
350
374
  "Invalid firmware maxBytes"
351
375
  }
352
- require(sha256Pattern.matches(params.expectedSha256)) {
376
+ val expectedSha256 = params.expectedSha256?.lowercase()
377
+ require(expectedSha256 == null || sha256Pattern.matches(expectedSha256)) {
353
378
  "Invalid firmware artifact SHA-256"
354
379
  }
355
380
  val overallDeadlineSeconds =
@@ -369,7 +394,8 @@ internal object FirmwareArtifactStore {
369
394
  return ValidatedDownload(
370
395
  expectedSize = expectedSize,
371
396
  maxBytes = maxBytes,
372
- expectedSha256 = params.expectedSha256.lowercase(),
397
+ expectedSha256 = expectedSha256,
398
+ downloadToken = expectedSha256 ?: sha256(params.url),
373
399
  hostname = url.host,
374
400
  overallDeadlineSeconds = overallDeadlineSeconds,
375
401
  )
@@ -379,26 +405,37 @@ internal object FirmwareArtifactStore {
379
405
  params: FirmwareArtifactDownloadParams,
380
406
  validated: ValidatedDownload,
381
407
  ): StoredFirmwareArtifact {
382
- val finalFile = artifactFile(validated.expectedSha256)
383
- validateStoredArtifactOrNull(
384
- finalFile,
385
- validated.expectedSize,
386
- validated.expectedSha256,
387
- )?.let { return it }
408
+ validated.expectedSha256?.let { expectedSha256 ->
409
+ val finalFile = artifactFile(expectedSha256)
410
+ validateDownloadedArtifactOrNull(
411
+ finalFile,
412
+ validated.expectedSize,
413
+ expectedSha256,
414
+ validated.maxBytes,
415
+ )?.let { return it }
416
+ }
388
417
 
418
+ val transactionToken = sha256(params.transactionId).take(16)
389
419
  val partialFile = File(
390
420
  root,
391
- "${validated.expectedSha256}.${params.taskId}.partial",
421
+ "${validated.downloadToken}.${params.taskId}.$transactionToken.partial",
392
422
  )
393
- if (partialFile.length() > validated.expectedSize) {
423
+ if (validated.expectedSha256 == null && partialFile.length() > 0) {
424
+ check(partialFile.delete()) { "Unverified firmware partial cannot be removed" }
425
+ } else if (partialFile.length() > validated.maxBytes) {
394
426
  check(partialFile.delete()) { "Invalid firmware partial cannot be removed" }
395
427
  }
396
- if (partialFile.length() == validated.expectedSize) {
397
- validateStoredArtifactOrNull(
428
+ if (
429
+ validated.expectedSize != null &&
430
+ partialFile.length() == validated.expectedSize
431
+ ) {
432
+ validateDownloadedArtifactOrNull(
398
433
  partialFile,
399
434
  validated.expectedSize,
400
435
  validated.expectedSha256,
436
+ validated.maxBytes,
401
437
  )?.let {
438
+ val finalFile = artifactFile(it.sha256)
402
439
  promoteAtomically(partialFile, finalFile)
403
440
  return StoredFirmwareArtifact(
404
441
  it.artifactRef,
@@ -475,15 +512,17 @@ internal object FirmwareArtifactStore {
475
512
  }
476
513
 
477
514
  val artifact = try {
478
- validateStoredArtifact(
515
+ validateDownloadedArtifact(
479
516
  partialFile,
480
517
  validated.expectedSize,
481
518
  validated.expectedSha256,
519
+ validated.maxBytes,
482
520
  )
483
521
  } catch (error: Throwable) {
484
522
  partialFile.delete()
485
523
  throw error
486
524
  }
525
+ val finalFile = artifactFile(artifact.sha256)
487
526
  promoteAtomically(partialFile, finalFile)
488
527
  return StoredFirmwareArtifact(
489
528
  artifact.artifactRef,
@@ -497,7 +536,7 @@ internal object FirmwareArtifactStore {
497
536
  response: Response,
498
537
  partialFile: File,
499
538
  resumeOffset: Long,
500
- expectedSize: Long,
539
+ expectedSize: Long?,
501
540
  maxBytes: Long,
502
541
  ) {
503
542
  require(response.code == 200 || response.code == 206) {
@@ -510,6 +549,7 @@ internal object FirmwareArtifactStore {
510
549
  response.header("Content-Range"),
511
550
  if (append) resumeOffset else 0,
512
551
  expectedSize,
552
+ maxBytes,
513
553
  )
514
554
  ) {
515
555
  "ARTIFACT_PROTOCOL_INVALID: firmware resume Content-Range is invalid"
@@ -542,7 +582,8 @@ internal object FirmwareArtifactStore {
542
582
  private fun validateContentRange(
543
583
  value: String?,
544
584
  expectedStart: Long,
545
- expectedTotal: Long,
585
+ expectedTotal: Long?,
586
+ maxBytes: Long,
546
587
  ): Boolean {
547
588
  val match = value
548
589
  ?.lowercase()
@@ -554,7 +595,39 @@ internal object FirmwareArtifactStore {
554
595
  return start == expectedStart &&
555
596
  end >= start &&
556
597
  end < total &&
557
- total == expectedTotal
598
+ (expectedTotal?.let { total == it } ?: (total in 1..maxBytes))
599
+ }
600
+
601
+ private fun validateDownloadedArtifactOrNull(
602
+ file: File,
603
+ expectedSize: Long?,
604
+ expectedSha256: String?,
605
+ maxBytes: Long,
606
+ ): StoredFirmwareArtifact? = try {
607
+ validateDownloadedArtifact(file, expectedSize, expectedSha256, maxBytes)
608
+ } catch (_: Throwable) {
609
+ null
610
+ }
611
+
612
+ private fun validateDownloadedArtifact(
613
+ file: File,
614
+ expectedSize: Long?,
615
+ expectedSha256: String?,
616
+ maxBytes: Long,
617
+ ): StoredFirmwareArtifact {
618
+ val size = file.length()
619
+ require(
620
+ file.isFile &&
621
+ size in 1..maxBytes &&
622
+ (expectedSize == null || size == expectedSize)
623
+ ) {
624
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact size mismatch"
625
+ }
626
+ val sha256 = hashFile(file)
627
+ require(expectedSha256 == null || sha256 == expectedSha256) {
628
+ "ARTIFACT_INTEGRITY_FAILED: firmware artifact SHA-256 mismatch"
629
+ }
630
+ return StoredFirmwareArtifact("fw:$sha256", size, sha256, file)
558
631
  }
559
632
 
560
633
  private fun validateStoredArtifactOrNull(
@@ -646,35 +719,12 @@ internal object FirmwareArtifactStore {
646
719
  val openFiles = synchronized(readerLock) {
647
720
  readers.values.mapTo(mutableSetOf()) { it.file.absolutePath }
648
721
  }
649
- val now = System.currentTimeMillis()
650
- var deletedFiles = 0
651
- var deletedBytes = 0L
652
- root.listFiles()?.forEach { file ->
653
- if (!file.isFile) return@forEach
654
- val sha256 = file.name.take(64)
655
- if (
656
- !sha256Pattern.matches(sha256) ||
657
- sha256 in retainedSha256 ||
658
- sha256 in activeSha256 ||
659
- file.absolutePath in openFiles
660
- ) {
661
- return@forEach
662
- }
663
- val grace = if (file.name.endsWith(".bin")) {
664
- FINAL_ARTIFACT_GRACE_MS
665
- } else if (file.name.endsWith(".partial")) {
666
- PARTIAL_ARTIFACT_GRACE_MS
667
- } else {
668
- return@forEach
669
- }
670
- if (now - file.lastModified() < grace) return@forEach
671
- val size = file.length()
672
- if (file.delete()) {
673
- deletedFiles += 1
674
- deletedBytes += size
675
- }
676
- }
677
- return deletedFiles to deletedBytes
722
+ return sweepFirmwareArtifactOrphansAtRoot(
723
+ root = root,
724
+ retainedSha256 = retainedSha256,
725
+ activeSha256 = activeSha256,
726
+ openPaths = openFiles,
727
+ )
678
728
  }
679
729
 
680
730
  private fun requireLease(leaseRef: String) {
@@ -685,6 +735,15 @@ internal object FirmwareArtifactStore {
685
735
  }
686
736
  }
687
737
 
738
+ private fun requireLeaseTransaction(leaseRef: String, transactionId: String) {
739
+ synchronized(leaseLock) {
740
+ val lease = leases[validateLeaseRef(leaseRef)]
741
+ require(lease?.transactionId == transactionId) {
742
+ "Firmware artifact lease transaction mismatch"
743
+ }
744
+ }
745
+ }
746
+
688
747
  private fun retainExpectedArtifact(
689
748
  leaseRef: String,
690
749
  transactionId: String?,
@@ -736,6 +795,11 @@ internal object FirmwareArtifactStore {
736
795
  return digest.digest().toHex()
737
796
  }
738
797
 
798
+ private fun sha256(value: String): String =
799
+ MessageDigest.getInstance("SHA-256")
800
+ .digest(value.toByteArray(Charsets.UTF_8))
801
+ .toHex()
802
+
739
803
  private fun promoteAtomically(source: File, destination: File) {
740
804
  destination.parentFile?.mkdirs()
741
805
  Os.rename(source.absolutePath, destination.absolutePath)
@@ -272,7 +272,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
272
272
 
273
273
  override fun getFirmwareArtifactCapabilities(): FirmwareArtifactCapabilities {
274
274
  return FirmwareArtifactCapabilities(
275
- firmwareArtifactProtocolVersion = 2.0,
275
+ firmwareArtifactProtocolVersion = 3.0,
276
276
  supportedRouteTypes = arrayOf("domain", "pinnedIp"),
277
277
  supportsArchiveMaterialization = true,
278
278
  maxReadBytes = FirmwareArtifactStore.MAX_READ_BYTES.toDouble(),
@@ -1,10 +1,19 @@
1
1
  package com.margelo.nitro.reactnativerangedownloader
2
2
 
3
+ import java.io.File
4
+ import java.io.FileOutputStream
5
+ import java.util.zip.ZipEntry
6
+ import java.util.zip.ZipOutputStream
3
7
  import org.junit.Assert.assertEquals
4
8
  import org.junit.Assert.assertThrows
9
+ import org.junit.Rule
5
10
  import org.junit.Test
11
+ import org.junit.rules.TemporaryFolder
6
12
 
7
13
  class FirmwareArchiveRulesTest {
14
+ @get:Rule
15
+ val temporaryFolder = TemporaryFolder()
16
+
8
17
  private fun entry(
9
18
  artifactId: String = "resource-entry",
10
19
  entryName: String = "assets/icon.png",
@@ -72,4 +81,34 @@ class FirmwareArchiveRulesTest {
72
81
  )
73
82
  }
74
83
  }
84
+
85
+ @Test
86
+ fun acceptsPortableEntriesWithoutExpectedIntegrityMetadata() {
87
+ val archive = createArchive("assets/icon.png", byteArrayOf(1, 2, 3))
88
+
89
+ val entries = FirmwareArchiveRules.validateCentralDirectory(archive, null)
90
+
91
+ assertEquals(1, entries.size)
92
+ assertEquals("assets/icon.png", entries.single().name)
93
+ assertEquals(3, entries.single().uncompressedSize)
94
+ }
95
+
96
+ @Test
97
+ fun rejectsTraversalWithoutExpectedIntegrityMetadata() {
98
+ val archive = createArchive("../icon.png", byteArrayOf(1))
99
+
100
+ assertThrows(IllegalArgumentException::class.java) {
101
+ FirmwareArchiveRules.validateCentralDirectory(archive, null)
102
+ }
103
+ }
104
+
105
+ private fun createArchive(entryName: String, content: ByteArray): File {
106
+ val archive = temporaryFolder.newFile("firmware.zip")
107
+ ZipOutputStream(FileOutputStream(archive)).use { zip ->
108
+ zip.putNextEntry(ZipEntry(entryName))
109
+ zip.write(content)
110
+ zip.closeEntry()
111
+ }
112
+ return archive
113
+ }
75
114
  }