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.
- package/README-CN.md +31 -31
- package/README.md +31 -31
- package/android/jni/HDiffPatch/.travis.yml +33 -0
- package/android/jni/HDiffPatch/builds/xcode/bestParams.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/hdiffz.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/android/jni/HDiffPatch/builds/xcode/hpatchz.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/lz4.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/lzma.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/testHashClash.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/android/jni/HDiffPatch/builds/xcode/unitTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/HDiffPatch/builds/xcode/zstd.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/android/jni/lzma/DOC/7zC.txt +187 -0
- package/android/jni/lzma/DOC/7zFormat.txt +469 -0
- package/android/jni/lzma/DOC/Methods.txt +173 -0
- package/android/jni/lzma/DOC/installer.txt +166 -0
- package/android/jni/lzma/DOC/lzma-history.txt +446 -0
- package/android/jni/lzma/DOC/lzma-sdk.txt +357 -0
- package/android/jni/lzma/DOC/lzma-specification.txt +1176 -0
- package/android/jni/lzma/DOC/lzma.txt +328 -0
- package/android/jni/lzma/bin/7zS2.sfx +0 -0
- package/android/jni/lzma/bin/7zS2con.sfx +0 -0
- package/android/jni/lzma/bin/7zSD.sfx +0 -0
- package/android/jni/lzma/bin/7zdec.exe +0 -0
- package/android/jni/lzma/bin/7zr.exe +0 -0
- package/android/jni/lzma/bin/installer/config.txt +5 -0
- package/android/jni/lzma/bin/installer/cr.bat +5 -0
- package/android/jni/lzma/bin/lzma.exe +0 -0
- package/android/jni/lzma/bin/x64/7zr.exe +0 -0
- package/android/lib/arm64-v8a/librnupdate.so +0 -0
- package/android/lib/armeabi-v7a/librnupdate.so +0 -0
- package/android/lib/x86/librnupdate.so +0 -0
- package/android/lib/x86_64/librnupdate.so +0 -0
- package/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +44 -0
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +124 -40
- package/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +125 -0
- package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +43 -11
- package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +2 -0
- package/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +90 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +79 -19
- package/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +21 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +33 -2
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleSupport.java +1 -0
- package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
- package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
- package/cpp/patch_core/archive_limits.h +38 -0
- package/cpp/patch_core/install_record.h +36 -0
- package/cpp/update_flow_core/flow_json.h +1 -0
- package/cpp/update_flow_core/update_flow_core.cpp +41 -7
- package/cpp/update_flow_core/update_flow_core.h +9 -0
- package/cpp/update_flow_core/update_flow_jni.cpp +12 -0
- package/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +2 -0
- package/harmony/pushy/src/main/cpp/pushy.cpp +37 -0
- package/harmony/pushy/src/main/ets/ArchiveLimits.ts +83 -0
- package/harmony/pushy/src/main/ets/DownloadTask.ts +126 -30
- package/harmony/pushy/src/main/ets/InstallRecord.ts +129 -0
- package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +24 -2
- package/harmony/pushy/src/main/ets/NativePatchCore.ts +5 -0
- package/harmony/pushy/src/main/ets/PushyTurboModule.ts +12 -0
- package/harmony/pushy/src/main/ets/UpdateContext.ts +78 -15
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +384 -25
- package/ios/RCTPushy/RCTPushyDownloader.mm +32 -0
- package/package.json +25 -2
- package/src/NativePushy.ts +13 -0
- package/src/client.ts +160 -43
- package/src/core.ts +17 -3
- package/src/error.ts +3 -0
- package/src/index.ts +8 -0
- package/src/locales/en.ts +3 -0
- package/src/locales/zh.ts +2 -0
- package/src/metadata.ts +166 -0
- package/src/provider.tsx +52 -17
- package/src/type.ts +22 -0
- package/src/updateFlowCore.ts +95 -4
- package/src/utils.ts +31 -19
- package/.gitmodules +0 -6
- package/BUNDLEHASH_MIGRATION.md +0 -355
- package/NATIVE_CHECKUPDATE_DESIGN.md +0 -539
- package/NATIVE_CHECK_FOLLOWUPS.md +0 -514
- package/android/jni/HDiffPatch/test/_private_searchBestParams.cpp +0 -301
- package/android/jni/HDiffPatch/test/hpatchz_test.cpp +0 -292
- package/android/jni/HDiffPatch/test/testHashClash.cpp +0 -436
- package/android/jni/HDiffPatch/test/unit_test.cpp +0 -417
- package/biome.json +0 -59
- package/cpp/patch_core/tests/fixtures/rawstored.lzma2label.patch.bin +0 -0
- package/cpp/patch_core/tests/fixtures/rawstored.new.bin +0 -0
- package/cpp/patch_core/tests/fixtures/rawstored.old.bin +0 -0
- package/cpp/patch_core/tests/fixtures/v96.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v96.meta.json +0 -1
- package/cpp/patch_core/tests/fixtures/v96.streampatch.bin +0 -0
- package/cpp/patch_core/tests/fixtures/v96.t.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v96.tpatch.bin +0 -0
- package/cpp/patch_core/tests/fixtures/v96.tstreampatch.bin +0 -0
- package/cpp/patch_core/tests/fixtures/v96b.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v98.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v98.t.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v98b.hbc +0 -0
- package/cpp/patch_core/tests/fixtures/v98b.t.hbc +0 -0
- package/cpp/patch_core/tests/hbc_transform_test.cpp +0 -384
- package/cpp/patch_core/tests/patch_core_test.cpp +0 -970
- package/cpp/update_flow_core/tests/flow_vectors.json +0 -1401
- package/cpp/update_flow_core/tests/update_flow_core_test.cpp +0 -317
- 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
|
-
|
|
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
|
-
|
|
520
|
-
UpdateFileUtils.
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
643
|
+
File originBundleFile = new File(work, ".origin.bundle");
|
|
563
644
|
copyBundledAssetToFile("index.android.bundle", originBundleFile);
|
|
564
645
|
try {
|
|
565
646
|
applyPatchFromFileSource(
|
|
566
|
-
|
|
567
|
-
|
|
647
|
+
work.getAbsolutePath(),
|
|
648
|
+
work.getAbsolutePath(),
|
|
568
649
|
originBundleFile.getAbsolutePath(),
|
|
569
|
-
new File(
|
|
570
|
-
new File(
|
|
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
|
-
|
|
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
|
-
|
|
683
|
+
work.getAbsolutePath(),
|
|
601
684
|
new File(params.originDirectory, "index.bundlejs").getAbsolutePath(),
|
|
602
|
-
new File(
|
|
603
|
-
new File(
|
|
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(
|
|
714
|
+
UpdateFileUtils.removeDirectory(stagingDirectory());
|
|
630
715
|
} catch (IOException ioException) {
|
|
631
|
-
Log.e(UpdateContext.TAG, "Failed to clean
|
|
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
|
-
&&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|