react-native-update 10.53.1 → 10.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README-CN.md +31 -31
  2. package/README.md +31 -31
  3. package/android/jni/HDiffPatch/.travis.yml +33 -0
  4. package/android/jni/HDiffPatch/builds/xcode/bestParams.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  5. package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  6. package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  7. package/android/jni/HDiffPatch/builds/xcode/hpatchz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  8. package/android/jni/HDiffPatch/builds/xcode/lz4.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  9. package/android/jni/HDiffPatch/builds/xcode/lzma.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  10. package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  11. package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  12. package/android/jni/HDiffPatch/builds/xcode/unitTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  13. package/android/jni/HDiffPatch/builds/xcode/zstd.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  14. package/android/jni/lzma/DOC/7zC.txt +187 -0
  15. package/android/jni/lzma/DOC/7zFormat.txt +469 -0
  16. package/android/jni/lzma/DOC/Methods.txt +173 -0
  17. package/android/jni/lzma/DOC/installer.txt +166 -0
  18. package/android/jni/lzma/DOC/lzma-history.txt +446 -0
  19. package/android/jni/lzma/DOC/lzma-sdk.txt +357 -0
  20. package/android/jni/lzma/DOC/lzma-specification.txt +1176 -0
  21. package/android/jni/lzma/DOC/lzma.txt +328 -0
  22. package/android/jni/lzma/bin/7zS2.sfx +0 -0
  23. package/android/jni/lzma/bin/7zS2con.sfx +0 -0
  24. package/android/jni/lzma/bin/7zSD.sfx +0 -0
  25. package/android/jni/lzma/bin/7zdec.exe +0 -0
  26. package/android/jni/lzma/bin/7zr.exe +0 -0
  27. package/android/jni/lzma/bin/installer/config.txt +5 -0
  28. package/android/jni/lzma/bin/installer/cr.bat +5 -0
  29. package/android/jni/lzma/bin/lzma.exe +0 -0
  30. package/android/jni/lzma/bin/x64/7zr.exe +0 -0
  31. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  32. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  33. package/android/lib/x86/librnupdate.so +0 -0
  34. package/android/lib/x86_64/librnupdate.so +0 -0
  35. package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +44 -0
  36. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +124 -40
  37. package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +125 -0
  38. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +43 -11
  39. package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +2 -0
  40. package/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +90 -0
  41. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +79 -19
  42. package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +21 -0
  43. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +33 -2
  44. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleSupport.java +1 -0
  45. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  46. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  47. package/cpp/patch_core/archive_limits.h +38 -0
  48. package/cpp/patch_core/install_record.h +36 -0
  49. package/cpp/update_flow_core/flow_json.h +1 -0
  50. package/cpp/update_flow_core/update_flow_core.cpp +41 -7
  51. package/cpp/update_flow_core/update_flow_core.h +9 -0
  52. package/cpp/update_flow_core/update_flow_jni.cpp +12 -0
  53. package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +2 -0
  54. package/harmony/pushy/src/main/cpp/pushy.cpp +37 -0
  55. package/harmony/pushy/src/main/ets/ArchiveLimits.ts +83 -0
  56. package/harmony/pushy/src/main/ets/DownloadTask.ts +126 -30
  57. package/harmony/pushy/src/main/ets/InstallRecord.ts +129 -0
  58. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +24 -2
  59. package/harmony/pushy/src/main/ets/NativePatchCore.ts +5 -0
  60. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +12 -0
  61. package/harmony/pushy/src/main/ets/UpdateContext.ts +78 -15
  62. package/harmony/pushy.har +0 -0
  63. package/ios/RCTPushy/RCTPushy.mm +384 -25
  64. package/ios/RCTPushy/RCTPushyDownloader.mm +32 -0
  65. package/package.json +25 -2
  66. package/src/NativePushy.ts +13 -0
  67. package/src/client.ts +160 -43
  68. package/src/core.ts +17 -3
  69. package/src/error.ts +3 -0
  70. package/src/index.ts +8 -0
  71. package/src/locales/en.ts +3 -0
  72. package/src/locales/zh.ts +2 -0
  73. package/src/metadata.ts +166 -0
  74. package/src/provider.tsx +52 -17
  75. package/src/type.ts +22 -0
  76. package/src/updateFlowCore.ts +95 -4
  77. package/src/utils.ts +31 -19
  78. package/.gitmodules +0 -6
  79. package/BUNDLEHASH_MIGRATION.md +0 -355
  80. package/NATIVE_CHECKUPDATE_DESIGN.md +0 -539
  81. package/NATIVE_CHECK_FOLLOWUPS.md +0 -514
  82. package/android/jni/HDiffPatch/test/_private_searchBestParams.cpp +0 -301
  83. package/android/jni/HDiffPatch/test/hpatchz_test.cpp +0 -292
  84. package/android/jni/HDiffPatch/test/testHashClash.cpp +0 -436
  85. package/android/jni/HDiffPatch/test/unit_test.cpp +0 -417
  86. package/biome.json +0 -59
  87. package/cpp/patch_core/tests/fixtures/rawstored.lzma2label.patch.bin +0 -0
  88. package/cpp/patch_core/tests/fixtures/rawstored.new.bin +0 -0
  89. package/cpp/patch_core/tests/fixtures/rawstored.old.bin +0 -0
  90. package/cpp/patch_core/tests/fixtures/v96.hbc +0 -0
  91. package/cpp/patch_core/tests/fixtures/v96.meta.json +0 -1
  92. package/cpp/patch_core/tests/fixtures/v96.streampatch.bin +0 -0
  93. package/cpp/patch_core/tests/fixtures/v96.t.hbc +0 -0
  94. package/cpp/patch_core/tests/fixtures/v96.tpatch.bin +0 -0
  95. package/cpp/patch_core/tests/fixtures/v96.tstreampatch.bin +0 -0
  96. package/cpp/patch_core/tests/fixtures/v96b.hbc +0 -0
  97. package/cpp/patch_core/tests/fixtures/v98.hbc +0 -0
  98. package/cpp/patch_core/tests/fixtures/v98.t.hbc +0 -0
  99. package/cpp/patch_core/tests/fixtures/v98b.hbc +0 -0
  100. package/cpp/patch_core/tests/fixtures/v98b.t.hbc +0 -0
  101. package/cpp/patch_core/tests/hbc_transform_test.cpp +0 -384
  102. package/cpp/patch_core/tests/patch_core_test.cpp +0 -970
  103. package/cpp/update_flow_core/tests/flow_vectors.json +0 -1401
  104. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +0 -317
  105. package/scripts/check-expo-version.js +0 -92
@@ -48,6 +48,18 @@ class DownloadTask implements Runnable {
48
48
  NativeUpdateCore.ensureLoaded();
49
49
  }
50
50
 
51
+ // Two-phase install (cpp/patch_core/install_record.h): all unpack/patch
52
+ // work happens in <hash>.staging; the final <hash> directory only ever
53
+ // appears through an atomic rename after the completion record was
54
+ // written, so a crash or failure can never leave a half-installed
55
+ // directory that looks like a version.
56
+ private File stagingDirectory() {
57
+ return InstallRecord.stagingDirectoryFor(params.unzipDirectory);
58
+ }
59
+
60
+ // SHA-256 of the downloaded archive, computed right before extraction.
61
+ private String artifactSha256 = "";
62
+
51
63
  private static final class PatchArchiveContents {
52
64
  final ArrayList<String> entryNames = new ArrayList<String>();
53
65
  final ArrayList<String> copyFroms = new ArrayList<String>();
@@ -287,6 +299,7 @@ class DownloadTask implements Runnable {
287
299
  }
288
300
 
289
301
  try (Response response = requestClient.newCall(builder.build()).execute()) {
302
+ rejectProtocolDowngrade(url, response);
290
303
  if (response.code() == 416) {
291
304
  long total = resumeMeta == null ? 0 : resumeMeta.optLong("total", 0);
292
305
  if (total > 0 && writePath.length() == total) {
@@ -333,6 +346,11 @@ class DownloadTask implements Runnable {
333
346
  } else {
334
347
  totalAll = contentLength > 0 ? contentLength : 0;
335
348
  }
349
+ if (totalAll > ArchiveLimits.MAX_ARCHIVE_BYTES) {
350
+ throw new IOException("archive too large: " + totalAll + " bytes");
351
+ }
352
+ ArchiveLimits.ensureFreeSpace(
353
+ writePath, totalAll > 0 ? totalAll - baseOffset : 0);
336
354
  if (!append) {
337
355
  // Destroy the old bytes before the sidecar can vouch for
338
356
  // them with the new validators (a crash between the two
@@ -364,6 +382,11 @@ class DownloadTask implements Runnable {
364
382
  sink.emit();
365
383
 
366
384
  long overall = baseOffset + received;
385
+ if (overall > ArchiveLimits.MAX_ARCHIVE_BYTES) {
386
+ // Unknown/chunked length backstop.
387
+ throw new IOException(
388
+ "archive too large: exceeded " + ArchiveLimits.MAX_ARCHIVE_BYTES);
389
+ }
367
390
  if (totalAll > 0) {
368
391
  int percentage = (int) (overall * 100.0 / totalAll + 0.5);
369
392
  if (percentage > currentPercentage) {
@@ -396,12 +419,21 @@ class DownloadTask implements Runnable {
396
419
  }
397
420
 
398
421
  private byte[] readBytes(InputStream input) throws IOException {
422
+ return readBytes(input, Long.MAX_VALUE);
423
+ }
424
+
425
+ private byte[] readBytes(InputStream input, long maxBytes) throws IOException {
399
426
  try (
400
427
  InputStream in = input;
401
428
  ByteArrayOutputStream out = new ByteArrayOutputStream()
402
429
  ) {
403
430
  int count;
431
+ long total = 0;
404
432
  while ((count = in.read(buffer)) != -1) {
433
+ total += count;
434
+ if (total > maxBytes) {
435
+ throw new IOException("content exceeds " + maxBytes + " bytes");
436
+ }
405
437
  out.write(buffer, 0, count);
406
438
  }
407
439
  return out.toByteArray();
@@ -486,6 +518,7 @@ class DownloadTask implements Runnable {
486
518
 
487
519
  PatchArchiveContents contents = new PatchArchiveContents();
488
520
  try (SafeZipFile zipFile = new SafeZipFile(archiveFile)) {
521
+ ensureArchiveWithinLimits(archiveFile, zipFile, unzipDirectory);
489
522
  Enumeration<? extends ZipEntry> entries = zipFile.entries();
490
523
  while (entries.hasMoreElements()) {
491
524
  ZipEntry entry = entries.nextElement();
@@ -493,7 +526,11 @@ class DownloadTask implements Runnable {
493
526
  contents.entryNames.add(name);
494
527
 
495
528
  if (name.equals("__diff.json")) {
496
- byte[] bytes = readBytes(zipFile.getInputStream(entry));
529
+ if (entry.getSize() > ArchiveLimits.MAX_MANIFEST_BYTES) {
530
+ throw new IOException("patch manifest too large: " + entry.getSize());
531
+ }
532
+ byte[] bytes = readBytes(
533
+ zipFile.getInputStream(entry), ArchiveLimits.MAX_MANIFEST_BYTES);
497
534
  String json = new String(bytes, StandardCharsets.UTF_8);
498
535
  JSONObject manifest = (JSONObject) new JSONTokener(json).nextValue();
499
536
  appendManifestEntries(
@@ -516,19 +553,61 @@ class DownloadTask implements Runnable {
516
553
  private void doFullPatch() throws IOException {
517
554
  downloadFile();
518
555
 
519
- UpdateFileUtils.removeDirectory(params.unzipDirectory);
520
- UpdateFileUtils.ensureDirectory(params.unzipDirectory);
556
+ File work = stagingDirectory();
557
+ UpdateFileUtils.removeDirectory(work);
558
+ UpdateFileUtils.ensureDirectory(work);
559
+ artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile);
521
560
 
522
561
  try (SafeZipFile zipFile = new SafeZipFile(params.targetFile)) {
562
+ ensureArchiveWithinLimits(params.targetFile, zipFile, work);
523
563
  Enumeration<? extends ZipEntry> entries = zipFile.entries();
524
564
  while (entries.hasMoreElements()) {
525
- zipFile.unzipToPath(entries.nextElement(), params.unzipDirectory);
565
+ zipFile.unzipToPath(entries.nextElement(), work);
526
566
  }
527
567
  }
528
568
 
529
569
  deleteConsumedArchive();
530
570
  }
531
571
 
572
+ /**
573
+ * Last step of a successful patch task: the completion record (with the
574
+ * final bundle's digest) goes into the staging directory, which is then
575
+ * renamed over the version directory in one atomic step.
576
+ */
577
+ private void promoteStaging() throws IOException, JSONException {
578
+ File work = stagingDirectory();
579
+ File bundle = new File(work, "index.bundlejs");
580
+ if (!bundle.isFile()) {
581
+ throw new IOException("bundle missing after install: " + bundle);
582
+ }
583
+ String bundleSha256 = UpdateFileUtils.sha256Hex(bundle);
584
+ InstallRecord.write(
585
+ work, InstallRecord.build(params.hash, bundleSha256, artifactSha256));
586
+ if (params.unzipDirectory.exists()) {
587
+ UpdateFileUtils.removeDirectory(params.unzipDirectory);
588
+ }
589
+ if (!work.renameTo(params.unzipDirectory)) {
590
+ throw new IOException("failed to promote staging directory to " + params.unzipDirectory);
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Resource caps before extraction (cpp/patch_core/archive_limits.h): the
596
+ * archive itself, the central directory's declared contents, and the
597
+ * free space the expansion needs. Failing here is a PATCH_FAILED (the
598
+ * bytes arrived; they cannot be applied).
599
+ */
600
+ private static void ensureArchiveWithinLimits(
601
+ File archiveFile, SafeZipFile zipFile, File unzipDirectory
602
+ ) throws IOException {
603
+ long archiveBytes = archiveFile.length();
604
+ if (archiveBytes > ArchiveLimits.MAX_ARCHIVE_BYTES) {
605
+ throw new IOException("archive too large: " + archiveBytes + " bytes");
606
+ }
607
+ SafeZipFile.Inspection inspection = zipFile.inspect();
608
+ ArchiveLimits.ensureFreeSpace(unzipDirectory, inspection.totalUncompressed);
609
+ }
610
+
532
611
  // The archive and its resume sidecar live and die together: once the
533
612
  // archive is consumed (or classified as poisoned) the sidecar must not
534
613
  // survive to vouch for a file that no longer exists or cannot be trusted.
@@ -541,7 +620,9 @@ class DownloadTask implements Runnable {
541
620
 
542
621
  private void doPatchFromApk() throws IOException, JSONException {
543
622
  downloadFile();
544
- PatchArchiveContents contents = extractPatchArchive(params.targetFile, params.unzipDirectory);
623
+ File work = stagingDirectory();
624
+ artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile);
625
+ PatchArchiveContents contents = extractPatchArchive(params.targetFile, work);
545
626
 
546
627
  buildArchivePatchPlan(
547
628
  DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK,
@@ -552,22 +633,22 @@ class DownloadTask implements Runnable {
552
633
  );
553
634
 
554
635
  HashMap<String, ArrayList<File>> copyList = buildCopyList(
555
- params.unzipDirectory,
636
+ work,
556
637
  buildCopyGroups(
557
638
  contents.copyFroms.toArray(new String[0]),
558
639
  contents.copyTos.toArray(new String[0])
559
640
  )
560
641
  );
561
642
 
562
- File originBundleFile = new File(params.unzipDirectory, ".origin.bundle");
643
+ File originBundleFile = new File(work, ".origin.bundle");
563
644
  copyBundledAssetToFile("index.android.bundle", originBundleFile);
564
645
  try {
565
646
  applyPatchFromFileSource(
566
- params.unzipDirectory.getAbsolutePath(),
567
- params.unzipDirectory.getAbsolutePath(),
647
+ work.getAbsolutePath(),
648
+ work.getAbsolutePath(),
568
649
  originBundleFile.getAbsolutePath(),
569
- new File(params.unzipDirectory, "index.bundlejs.patch").getAbsolutePath(),
570
- new File(params.unzipDirectory, "index.bundlejs").getAbsolutePath(),
650
+ new File(work, "index.bundlejs.patch").getAbsolutePath(),
651
+ new File(work, "index.bundlejs").getAbsolutePath(),
571
652
  "",
572
653
  false,
573
654
  new String[0],
@@ -585,7 +666,9 @@ class DownloadTask implements Runnable {
585
666
 
586
667
  private void doPatchFromPpk() throws IOException, JSONException {
587
668
  downloadFile();
588
- PatchArchiveContents contents = extractPatchArchive(params.targetFile, params.unzipDirectory);
669
+ File work = stagingDirectory();
670
+ artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile);
671
+ PatchArchiveContents contents = extractPatchArchive(params.targetFile, work);
589
672
 
590
673
  ArchivePatchPlanResult plan = buildArchivePatchPlan(
591
674
  DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK,
@@ -597,10 +680,10 @@ class DownloadTask implements Runnable {
597
680
 
598
681
  applyPatchFromFileSource(
599
682
  params.originDirectory.getAbsolutePath(),
600
- params.unzipDirectory.getAbsolutePath(),
683
+ work.getAbsolutePath(),
601
684
  new File(params.originDirectory, "index.bundlejs").getAbsolutePath(),
602
- new File(params.unzipDirectory, "index.bundlejs.patch").getAbsolutePath(),
603
- new File(params.unzipDirectory, "index.bundlejs").getAbsolutePath(),
685
+ new File(work, "index.bundlejs.patch").getAbsolutePath(),
686
+ new File(work, "index.bundlejs").getAbsolutePath(),
604
687
  plan.mergeSourceSubdir,
605
688
  plan.enableMerge,
606
689
  contents.copyFroms.toArray(new String[0]),
@@ -625,10 +708,12 @@ class DownloadTask implements Runnable {
625
708
  case DownloadTaskParams.TASK_TYPE_PATCH_FULL:
626
709
  case DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK:
627
710
  case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK:
711
+ // Only the staging directory is ours to drop: the final
712
+ // version directory is never touched by a failed install.
628
713
  try {
629
- UpdateFileUtils.removeDirectory(params.unzipDirectory);
714
+ UpdateFileUtils.removeDirectory(stagingDirectory());
630
715
  } catch (IOException ioException) {
631
- Log.e(UpdateContext.TAG, "Failed to clean patched directory", ioException);
716
+ Log.e(UpdateContext.TAG, "Failed to clean staging directory", ioException);
632
717
  }
633
718
  if (downloadPhaseCompleted) {
634
719
  // Fully received but failed to unzip/patch: the archive is
@@ -653,6 +738,23 @@ class DownloadTask implements Runnable {
653
738
  }
654
739
  }
655
740
 
741
+ /**
742
+ * An https artifact URL must stay on https through every redirect: the
743
+ * package is the supply-chain boundary and TLS is what authenticates
744
+ * it. OkHttp follows cross-scheme redirects by default, so the final
745
+ * request is checked here (the redirected bytes are discarded).
746
+ */
747
+ static void rejectProtocolDowngrade(String requestedUrl, Response response)
748
+ throws IOException {
749
+ if (requestedUrl != null
750
+ && requestedUrl.regionMatches(true, 0, "https:", 0, 6)
751
+ && !response.request().url().isHttps()) {
752
+ throw new IOException(
753
+ "https download redirected to plaintext http: "
754
+ + response.request().url());
755
+ }
756
+ }
757
+
656
758
  private boolean isPatchTask(int taskType) {
657
759
  return taskType == DownloadTaskParams.TASK_TYPE_PATCH_FULL
658
760
  || taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK
@@ -661,11 +763,9 @@ class DownloadTask implements Runnable {
661
763
 
662
764
  private boolean hasCompletedPatchDirectory() {
663
765
  return params.unzipDirectory != null
766
+ && params.hash != null
664
767
  && new File(params.unzipDirectory, "index.bundlejs").isFile()
665
- && new File(
666
- params.unzipDirectory,
667
- UpdateContext.VERSION_COMPLETE_FILE
668
- ).isFile();
768
+ && InstallRecord.isComplete(params.unzipDirectory, params.hash);
669
769
  }
670
770
 
671
771
  @Override
@@ -681,12 +781,15 @@ class DownloadTask implements Runnable {
681
781
  switch (taskType) {
682
782
  case DownloadTaskParams.TASK_TYPE_PATCH_FULL:
683
783
  doFullPatch();
784
+ promoteStaging();
684
785
  break;
685
786
  case DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK:
686
787
  doPatchFromApk();
788
+ promoteStaging();
687
789
  break;
688
790
  case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK:
689
791
  doPatchFromPpk();
792
+ promoteStaging();
690
793
  break;
691
794
  case DownloadTaskParams.TASK_TYPE_CLEANUP:
692
795
  doCleanUp();
@@ -731,25 +834,6 @@ class DownloadTask implements Runnable {
731
834
  deleteResumeSidecar(params.targetFile);
732
835
  }
733
836
 
734
- if (isPatchTask(taskType) && !alreadyCompleted) {
735
- try {
736
- File marker = new File(
737
- params.unzipDirectory,
738
- UpdateContext.VERSION_COMPLETE_FILE
739
- );
740
- if (!marker.createNewFile() && !marker.isFile()) {
741
- throw new IOException("Failed to mark completed update: " + marker);
742
- }
743
- } catch (Throwable error) {
744
- Log.e(UpdateContext.TAG, "failed to mark completed update", error);
745
- cleanUpAfterFailure(taskType);
746
- if (params.listener != null) {
747
- params.listener.onDownloadFailed(error);
748
- }
749
- return;
750
- }
751
- }
752
-
753
837
  // The task itself succeeded. Run the completion callback outside the
754
838
  // try/catch above so an exception thrown by the callback (e.g. a
755
839
  // PackageInstaller failure during APK staging) is not mistaken for
@@ -0,0 +1,125 @@
1
+ package cn.reactnative.modules.update;
2
+
3
+ import java.io.File;
4
+ import java.io.FileInputStream;
5
+ import java.io.FileOutputStream;
6
+ import java.io.IOException;
7
+ import java.nio.charset.StandardCharsets;
8
+ import org.json.JSONException;
9
+ import org.json.JSONObject;
10
+
11
+ /**
12
+ * Java mirror of cpp/patch_core/install_record.h — keep in sync by hand.
13
+ * The completion record written as the last step of a two-phase install and
14
+ * read back before a version directory is trusted.
15
+ */
16
+ final class InstallRecord {
17
+ static final int SCHEMA = 1;
18
+ static final String FILE_NAME = ".pushy-complete";
19
+ static final String STAGING_SUFFIX = ".staging";
20
+
21
+ private InstallRecord() {
22
+ }
23
+
24
+ static File stagingDirectoryFor(File versionDir) {
25
+ return new File(versionDir.getPath() + STAGING_SUFFIX);
26
+ }
27
+
28
+ static String build(String versionHash, String bundleSha256, String artifactSha256)
29
+ throws JSONException {
30
+ JSONObject record = new JSONObject();
31
+ record.put("schema", SCHEMA);
32
+ record.put("versionHash", versionHash);
33
+ if (bundleSha256 != null && !bundleSha256.isEmpty()) {
34
+ record.put("bundleSha256", bundleSha256);
35
+ }
36
+ if (artifactSha256 != null && !artifactSha256.isEmpty()) {
37
+ record.put("artifactSha256", artifactSha256);
38
+ }
39
+ return record.toString();
40
+ }
41
+
42
+ /** Writes the record and fsyncs it: the rename that follows must find it on disk. */
43
+ static void write(File versionDir, String recordJson) throws IOException {
44
+ File file = new File(versionDir, FILE_NAME);
45
+ try (FileOutputStream out = new FileOutputStream(file)) {
46
+ out.write(recordJson.getBytes(StandardCharsets.UTF_8));
47
+ out.flush();
48
+ out.getFD().sync();
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Parsed record, or null when the file is absent or malformed. An empty
54
+ * file (legacy marker) yields an empty JSONObject.
55
+ */
56
+ static JSONObject read(File versionDir) {
57
+ File file = new File(versionDir, FILE_NAME);
58
+ if (!file.isFile()) {
59
+ return null;
60
+ }
61
+ if (file.length() == 0) {
62
+ return new JSONObject();
63
+ }
64
+ if (file.length() > 64 * 1024) {
65
+ return null;
66
+ }
67
+ try (FileInputStream in = new FileInputStream(file)) {
68
+ byte[] bytes = new byte[(int) file.length()];
69
+ int offset = 0;
70
+ while (offset < bytes.length) {
71
+ int read = in.read(bytes, offset, bytes.length - offset);
72
+ if (read < 0) {
73
+ break;
74
+ }
75
+ offset += read;
76
+ }
77
+ return new JSONObject(new String(bytes, 0, offset, StandardCharsets.UTF_8));
78
+ } catch (IOException | JSONException e) {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Presence check: the record exists and, unless legacy-empty, names this
85
+ * version. No digest work — this runs on launch / dedup paths.
86
+ */
87
+ static boolean isComplete(File versionDir, String versionHash) {
88
+ JSONObject record = read(versionDir);
89
+ if (record == null) {
90
+ return false;
91
+ }
92
+ if (record.length() == 0) {
93
+ return true;
94
+ }
95
+ return record.optInt("schema", 0) == SCHEMA
96
+ && versionHash.equals(record.optString("versionHash", ""));
97
+ }
98
+
99
+ /**
100
+ * Activation check: re-hashes the bundle when the record carries a digest.
101
+ * Throws with the reason when the directory must not be activated.
102
+ */
103
+ static void verifyForActivation(File versionDir, String versionHash, File bundleFile)
104
+ throws IOException {
105
+ JSONObject record = read(versionDir);
106
+ if (record == null) {
107
+ throw new IOException("Bundle version " + versionHash + " has no valid completion record.");
108
+ }
109
+ if (record.length() == 0) {
110
+ return;
111
+ }
112
+ if (record.optInt("schema", 0) != SCHEMA
113
+ || !versionHash.equals(record.optString("versionHash", ""))) {
114
+ throw new IOException("Bundle version " + versionHash + " completion record mismatch.");
115
+ }
116
+ String expected = record.optString("bundleSha256", "");
117
+ if (expected.isEmpty()) {
118
+ return;
119
+ }
120
+ String actual = UpdateFileUtils.sha256Hex(bundleFile);
121
+ if (!expected.equalsIgnoreCase(actual)) {
122
+ throw new IOException("Bundle version " + versionHash + " bundle digest mismatch.");
123
+ }
124
+ }
125
+ }
@@ -58,6 +58,25 @@ final class NativeCheckOrchestrator {
58
58
  private static volatile long unactivatedGeneration;
59
59
  private static volatile UpdateContext sContext;
60
60
  private static volatile String sLaunchRolledBackVersion;
61
+ // Config JSON for which JS reported a completed check in this process
62
+ // (markJsCheckCompleted). Process-scoped by design: the next launch
63
+ // starts with no signal and the cold-start round runs again.
64
+ private static volatile String sJsCompletedConfig;
65
+
66
+ static void markJsCheckCompleted(String config) {
67
+ sJsCompletedConfig = config;
68
+ }
69
+
70
+ /**
71
+ * True when JS already obtained a valid response in this process for the
72
+ * exact config the native round would use: the delayed round is then a
73
+ * duplicate request. Only the scheduled round consults this — the
74
+ * crash-rescue path still runs, JS is dead by then.
75
+ */
76
+ private static boolean isJsCheckCompleted(UpdateContext context) {
77
+ String jsConfig = sJsCompletedConfig;
78
+ return jsConfig != null && jsConfig.equals(context.getKv(KEY_CONFIG));
79
+ }
61
80
 
62
81
  private NativeCheckOrchestrator() {
63
82
  }
@@ -86,6 +105,13 @@ final class NativeCheckOrchestrator {
86
105
  if (context.getKv(KEY_ROUND_INCOMPLETE) == null) {
87
106
  Thread.sleep(5000);
88
107
  }
108
+ if (isJsCheckCompleted(context)) {
109
+ // Not consuming the round: a later crash rescue may
110
+ // still need it.
111
+ Log.i(UpdateContext.TAG,
112
+ "native check skipped: JS check completed in this process");
113
+ return;
114
+ }
89
115
  startRound(0);
90
116
  } catch (Throwable e) {
91
117
  // The rescue path must never take the app down with it.
@@ -211,13 +237,21 @@ final class NativeCheckOrchestrator {
211
237
  }
212
238
  // From here on the round does real work: leave the breadcrumb that
213
239
  // the next launch reads to skip its 5s delay if we die mid-round.
214
- context.setKv(KEY_ROUND_INCOMPLETE, "1");
240
+ // Best-effort: a lost breadcrumb costs one 5s delay, it must not
241
+ // abort the rescue round itself.
242
+ try {
243
+ context.setKv(KEY_ROUND_INCOMPLETE, "1");
244
+ } catch (IllegalStateException ignored) {
245
+ }
215
246
  try {
216
247
  runConfiguredRound(
217
248
  context, launchRolledBackVersion, deadlineNanos,
218
249
  resetGeneration, configJson, config, appKey);
219
250
  } finally {
220
- context.removeKv(KEY_ROUND_INCOMPLETE);
251
+ try {
252
+ context.removeKv(KEY_ROUND_INCOMPLETE);
253
+ } catch (IllegalStateException ignored) {
254
+ }
221
255
  }
222
256
  }
223
257
 
@@ -430,6 +464,9 @@ final class NativeCheckOrchestrator {
430
464
  }
431
465
  }
432
466
  try (Response response = client.newCall(builder.build()).execute()) {
467
+ // Same rule as the artifact download: an https endpoint that
468
+ // redirects to plaintext http is a failed endpoint.
469
+ DownloadTask.rejectProtocolDowngrade(url, response);
433
470
  if (!response.isSuccessful() || response.body() == null) {
434
471
  return null;
435
472
  }
@@ -440,16 +477,11 @@ final class NativeCheckOrchestrator {
440
477
  }
441
478
  }
442
479
 
480
+ // Shared schema rule (update_flow_core::IsValidCheckResponse): a 200 with
481
+ // `{"error": ...}` is a failed endpoint, not a verdict, and must not stop
482
+ // the endpoint fallback.
443
483
  private static boolean isValidCheckResponse(String responseText) {
444
- if (responseText == null) {
445
- return false;
446
- }
447
- try {
448
- new JSONObject(responseText);
449
- return true;
450
- } catch (JSONException e) {
451
- return false;
452
- }
484
+ return responseText != null && NativeUpdateFlow.isValidCheckResponse(responseText);
453
485
  }
454
486
 
455
487
  private static String normalizeEndpointBase(String base) {
@@ -18,6 +18,8 @@ final class NativeUpdateFlow {
18
18
 
19
19
  static native String orderEndpointCandidates(String endpointsJson, double randomSample);
20
20
 
21
+ static native boolean isValidCheckResponse(String responseText);
22
+
21
23
  static native String handleCheckResponse(
22
24
  String responseText, String identityJson, String afterDownload);
23
25
  }
@@ -19,6 +19,73 @@ public class SafeZipFile extends ZipFile {
19
19
 
20
20
  private static final int BUFFER_SIZE = 8192;
21
21
 
22
+ // Bytes written by unzipToFile across the archive: the sum of the central
23
+ // directory's declared sizes is checked up front (inspect), the actual
24
+ // stream is re-checked here — headers can lie.
25
+ private long totalWritten = 0;
26
+
27
+ static final class Inspection {
28
+ long entries;
29
+ long totalUncompressed;
30
+ }
31
+
32
+ /**
33
+ * Walks the central directory once, enforcing the archive_limits caps
34
+ * (entry count, per-entry size, total size, compression ratio) and the
35
+ * reserved-name / traversal rules, before a single byte is extracted.
36
+ */
37
+ Inspection inspect() throws IOException {
38
+ Inspection result = new Inspection();
39
+ Enumeration<? extends ZipEntry> entries = entries();
40
+ while (entries.hasMoreElements()) {
41
+ ZipEntry entry = entries.nextElement();
42
+ result.entries++;
43
+ if (result.entries > ArchiveLimits.MAX_ENTRIES) {
44
+ throw new IOException(
45
+ "archive has too many entries (> " + ArchiveLimits.MAX_ENTRIES + ")");
46
+ }
47
+ long size = entry.getSize();
48
+ long compressed = entry.getCompressedSize();
49
+ if (size > ArchiveLimits.MAX_ENTRY_BYTES) {
50
+ throw new IOException(
51
+ "archive entry too large: " + entry.getName() + " (" + size + " bytes)");
52
+ }
53
+ if (size > 0) {
54
+ result.totalUncompressed += size;
55
+ if (result.totalUncompressed > ArchiveLimits.MAX_TOTAL_UNCOMPRESSED_BYTES) {
56
+ throw new IOException("archive expands beyond "
57
+ + ArchiveLimits.MAX_TOTAL_UNCOMPRESSED_BYTES + " bytes");
58
+ }
59
+ if (compressed > 0 && size > ArchiveLimits.RATIO_CHECK_MIN_BYTES
60
+ && size / compressed > ArchiveLimits.MAX_COMPRESSION_RATIO) {
61
+ throw new IOException(
62
+ "archive entry compression ratio too high: " + entry.getName());
63
+ }
64
+ }
65
+ }
66
+ return result;
67
+ }
68
+
69
+ // Files the SDK itself writes into a version directory to record its own
70
+ // decisions (the ".pushy-complete" install marker today). An archive
71
+ // must never be able to ship one: a package that unzips its own marker
72
+ // next to a bundle would be trusted as a finished install even when the
73
+ // patch step that follows fails.
74
+ static final String RESERVED_ENTRY_PREFIX = ".pushy-";
75
+
76
+ static boolean isReservedEntryName(String name) {
77
+ if (name == null) {
78
+ return false;
79
+ }
80
+ String normalized = name.replace('\\', '/');
81
+ while (normalized.endsWith("/")) {
82
+ normalized = normalized.substring(0, normalized.length() - 1);
83
+ }
84
+ int slash = normalized.lastIndexOf('/');
85
+ String baseName = slash >= 0 ? normalized.substring(slash + 1) : normalized;
86
+ return baseName.startsWith(RESERVED_ENTRY_PREFIX);
87
+ }
88
+
22
89
  @Override
23
90
  public Enumeration<? extends ZipEntry> entries() {
24
91
  return new SafeZipEntryIterator(super.entries());
@@ -48,6 +115,9 @@ public class SafeZipFile extends ZipFile {
48
115
  if (null != name && (name.contains("../") || name.contains("..\\"))) {
49
116
  throw new SecurityException("illegal entry: " + name);
50
117
  }
118
+ if (isReservedEntryName(name)) {
119
+ throw new SecurityException("reserved entry: " + name);
120
+ }
51
121
  }
52
122
  return entry;
53
123
  }
@@ -63,6 +133,9 @@ public class SafeZipFile extends ZipFile {
63
133
  if (!canonicalPath.startsWith(targetPath.getCanonicalPath() + File.separator)) {
64
134
  throw new SecurityException("Illegal name: " + name);
65
135
  }
136
+ if (isReservedEntryName(name)) {
137
+ throw new SecurityException("reserved entry: " + name);
138
+ }
66
139
 
67
140
  if (ze.isDirectory()) {
68
141
  target.mkdirs();
@@ -77,12 +150,29 @@ public class SafeZipFile extends ZipFile {
77
150
  throw new IOException("Failed to create parent dir for " + target);
78
151
  }
79
152
 
153
+ // The declared size is the budget when known; otherwise the global
154
+ // per-entry cap. Either way the stream may not exceed it.
155
+ long declared = ze.getSize();
156
+ long budget = declared >= 0
157
+ ? Math.min(declared, ArchiveLimits.MAX_ENTRY_BYTES)
158
+ : ArchiveLimits.MAX_ENTRY_BYTES;
159
+ long written = 0;
80
160
  try (InputStream inputStream = getInputStream(ze)) {
81
161
  try (BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(target));
82
162
  BufferedInputStream input = new BufferedInputStream(inputStream)) {
83
163
  byte[] buffer = new byte[BUFFER_SIZE];
84
164
  int n;
85
165
  while ((n = input.read(buffer, 0, BUFFER_SIZE)) >= 0) {
166
+ written += n;
167
+ totalWritten += n;
168
+ if (written > budget) {
169
+ throw new IOException(
170
+ "archive entry exceeds its declared size: " + ze.getName());
171
+ }
172
+ if (totalWritten > ArchiveLimits.MAX_TOTAL_UNCOMPRESSED_BYTES) {
173
+ throw new IOException("archive expands beyond "
174
+ + ArchiveLimits.MAX_TOTAL_UNCOMPRESSED_BYTES + " bytes");
175
+ }
86
176
  output.write(buffer, 0, n);
87
177
  }
88
178
  }