react-native-update 10.48.3 → 10.50.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 (41) hide show
  1. package/BUNDLEHASH_MIGRATION.md +355 -0
  2. package/NATIVE_CHECKUPDATE_DESIGN.md +192 -0
  3. package/android/jni/Android.mk +1 -0
  4. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  5. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  6. package/android/lib/x86/librnupdate.so +0 -0
  7. package/android/lib/x86_64/librnupdate.so +0 -0
  8. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +42 -9
  9. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +22 -1
  10. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
  11. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +125 -0
  12. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +23 -3
  13. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  14. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  15. package/cpp/patch_core/digest.cpp +230 -0
  16. package/cpp/patch_core/digest.h +61 -0
  17. package/cpp/patch_core/patch_core.cpp +17 -1
  18. package/cpp/patch_core/patch_core.h +8 -0
  19. package/cpp/patch_core/tests/patch_core_test.cpp +169 -0
  20. package/harmony/pushy/src/main/cpp/CMakeLists.txt +1 -0
  21. package/harmony/pushy/src/main/cpp/pushy.cpp +94 -0
  22. package/harmony/pushy/src/main/ets/DownloadTask.ts +85 -0
  23. package/harmony/pushy/src/main/ets/NativePatchCore.ts +4 -0
  24. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +11 -0
  25. package/harmony/pushy/src/main/ets/UpdateContext.ts +79 -4
  26. package/harmony/pushy.har +0 -0
  27. package/ios/RCTPushy/RCTPushy.mm +116 -7
  28. package/package.json +1 -1
  29. package/react-native-update.podspec +1 -0
  30. package/src/NativePushy.ts +6 -0
  31. package/src/client.ts +119 -7
  32. package/src/core.ts +27 -0
  33. package/src/endpoint.ts +98 -51
  34. package/src/error.ts +4 -0
  35. package/src/isInRollout.ts +2 -1
  36. package/src/locales/en.ts +6 -0
  37. package/src/locales/zh.ts +6 -0
  38. package/src/provider.tsx +36 -7
  39. package/src/telemetry.ts +8 -0
  40. package/src/type.ts +34 -3
  41. package/src/utils.ts +13 -0
@@ -1,4 +1,5 @@
1
1
  #include "../archive_patch_core.h"
2
+ #include "../digest.h"
2
3
  #include "../patch_core.h"
3
4
  #include "../state_core.h"
4
5
 
@@ -747,6 +748,168 @@ void TestStateCoreSwitchToSameVersion() {
747
748
  Expect(!switched.first_time_ok, "first_time_ok should be false");
748
749
  }
749
750
 
751
+ // NIST FIPS 180-4 vectors. These anchor the C++ implementation (iOS/Harmony)
752
+ // and, by transitivity, Android's java.security.MessageDigest and the CLI's
753
+ // node:crypto to the same standard — the bundleHash produced on any of them
754
+ // must be byte-identical for the same input.
755
+ void TestSha256KnownVectors() {
756
+ {
757
+ pushy::digest::Sha256 hasher;
758
+ ExpectEq(
759
+ hasher.HexDigest(),
760
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
761
+ "sha256 of empty input");
762
+ }
763
+ {
764
+ pushy::digest::Sha256 hasher;
765
+ const char* input = "abc";
766
+ hasher.Update(reinterpret_cast<const uint8_t*>(input), 3);
767
+ ExpectEq(
768
+ hasher.HexDigest(),
769
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
770
+ "sha256 of \"abc\"");
771
+ }
772
+ {
773
+ pushy::digest::Sha256 hasher;
774
+ const std::string input =
775
+ "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
776
+ hasher.Update(
777
+ reinterpret_cast<const uint8_t*>(input.data()), input.size());
778
+ ExpectEq(
779
+ hasher.HexDigest(),
780
+ "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1",
781
+ "sha256 of NIST two-block message");
782
+ }
783
+ }
784
+
785
+ void TestSha256StreamingMatchesOneShot() {
786
+ // One million 'a' (NIST long vector), fed in deliberately awkward chunk
787
+ // sizes to cross every buffer boundary case in Update.
788
+ const std::string chunk(4096 + 37, 'a');
789
+ size_t remaining = 1000000;
790
+ pushy::digest::Sha256 hasher;
791
+ while (remaining > 0) {
792
+ const size_t take = remaining < chunk.size() ? remaining : chunk.size();
793
+ hasher.Update(reinterpret_cast<const uint8_t*>(chunk.data()), take);
794
+ remaining -= take;
795
+ }
796
+ ExpectEq(
797
+ hasher.HexDigest(),
798
+ "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0",
799
+ "sha256 of one million 'a' fed in odd-sized chunks");
800
+ }
801
+
802
+ void TestSha256File() {
803
+ TempDir temp;
804
+ const std::string dir = temp.path;
805
+ const std::string path = dir + "/input.bin";
806
+ WriteFile(path, "abc");
807
+ ExpectEq(
808
+ pushy::digest::Sha256File(path),
809
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
810
+ "Sha256File over a small file");
811
+ ExpectEq(
812
+ pushy::digest::Sha256File(dir + "/missing.bin"),
813
+ "",
814
+ "Sha256File over a missing file returns empty");
815
+ }
816
+
817
+ void TestCrc32KnownVectors() {
818
+ {
819
+ pushy::digest::Crc32 crc;
820
+ Expect(crc.Value() == 0x00000000u, "crc32 of empty input");
821
+ }
822
+ {
823
+ // The classic check value: crc32("123456789") == 0xCBF43926.
824
+ pushy::digest::Crc32 crc;
825
+ const char* input = "123456789";
826
+ crc.Update(reinterpret_cast<const uint8_t*>(input), 9);
827
+ Expect(crc.Value() == 0xCBF43926u, "crc32 check value of \"123456789\"");
828
+ }
829
+ {
830
+ // Streaming in awkward chunks must match the one-shot value.
831
+ const std::string input(100000, 'a');
832
+ pushy::digest::Crc32 one_shot;
833
+ one_shot.Update(
834
+ reinterpret_cast<const uint8_t*>(input.data()), input.size());
835
+ pushy::digest::Crc32 streamed;
836
+ size_t offset = 0;
837
+ size_t chunk = 1;
838
+ while (offset < input.size()) {
839
+ const size_t take =
840
+ offset + chunk > input.size() ? input.size() - offset : chunk;
841
+ streamed.Update(
842
+ reinterpret_cast<const uint8_t*>(input.data()) + offset, take);
843
+ offset += take;
844
+ chunk = chunk * 2 + 1;
845
+ }
846
+ Expect(
847
+ streamed.Value() == one_shot.Value(),
848
+ "crc32 streaming matches one-shot");
849
+ }
850
+ }
851
+
852
+ void TestCrc32File() {
853
+ TempDir temp;
854
+ const std::string dir = temp.path;
855
+ const std::string path = dir + "/input.bin";
856
+ WriteFile(path, "123456789");
857
+ uint32_t value = 0;
858
+ Expect(pushy::digest::Crc32File(path, &value), "Crc32File should read");
859
+ Expect(value == 0xCBF43926u, "Crc32File check value");
860
+ Expect(
861
+ !pushy::digest::Crc32File(dir + "/missing.bin", &value),
862
+ "Crc32File over a missing file returns false");
863
+ }
864
+
865
+ void TestApplyPatchCopiesVerifyExpectedCrc() {
866
+ TempDir temp;
867
+ const std::string source = JoinPath(temp.path, "origin");
868
+ const std::string target = JoinPath(temp.path, "target");
869
+ const std::string patch = JoinPath(temp.path, "bundle.patch");
870
+
871
+ WriteFile(JoinPath(source, "index.bundlejs"), "old bundle");
872
+ WriteFile(JoinPath(source, "assets/keep.txt"), "123456789");
873
+ WriteFile(patch, "unused patch");
874
+
875
+ FakeBundlePatcher patcher("patched bundle");
876
+ FileSourcePatchOptions options;
877
+ options.source_root = source;
878
+ options.target_root = target;
879
+ options.origin_bundle_path = JoinPath(source, "index.bundlejs");
880
+ options.bundle_patch_path = patch;
881
+ options.bundle_output_path = JoinPath(target, "index.bundlejs");
882
+ options.enable_merge = false;
883
+ CopyOperation copy;
884
+ copy.from = "assets/keep.txt";
885
+ copy.to = "assets/keep.txt";
886
+ copy.has_expected_crc = true;
887
+ copy.expected_crc = 0xCBF43926u; // crc32("123456789")
888
+ options.manifest.copies.push_back(copy);
889
+
890
+ Status status = ApplyPatchFromFileSource(options, patcher);
891
+ Expect(status.ok, status.message);
892
+ ExpectEq(
893
+ ReadFile(JoinPath(target, "assets/keep.txt")),
894
+ "123456789",
895
+ "verified copy content");
896
+
897
+ // Same manifest applied on a "rebuilt" source whose file drifted: the copy
898
+ // must fail the whole patch (caller falls back to full), not copy silently.
899
+ WriteFile(JoinPath(source, "assets/keep.txt"), "drifted-bytes");
900
+ const std::string target2 = JoinPath(temp.path, "target2");
901
+ options.target_root = target2;
902
+ options.bundle_output_path = JoinPath(target2, "index.bundlejs");
903
+ Status mismatch = ApplyPatchFromFileSource(options, patcher);
904
+ Expect(!mismatch.ok, "drifted copy source must fail the patch");
905
+ Expect(
906
+ mismatch.message.find("crc32") != std::string::npos,
907
+ "mismatch error should mention crc32");
908
+ Expect(
909
+ !Exists(JoinPath(target2, "assets/keep.txt")),
910
+ "drifted content must not be copied");
911
+ }
912
+
750
913
  } // namespace
751
914
 
752
915
  int main(int argc, char** argv) {
@@ -778,6 +941,12 @@ int main(int argc, char** argv) {
778
941
  {"StateCoreRollbackToEmptyVersion", TestStateCoreRollbackToEmptyVersion},
779
942
  {"StateCoreResolveLaunchNoCurrentVersion", TestStateCoreResolveLaunchNoCurrentVersion},
780
943
  {"StateCoreSwitchToSameVersion", TestStateCoreSwitchToSameVersion},
944
+ {"Sha256KnownVectors", TestSha256KnownVectors},
945
+ {"Sha256StreamingMatchesOneShot", TestSha256StreamingMatchesOneShot},
946
+ {"Sha256File", TestSha256File},
947
+ {"Crc32KnownVectors", TestCrc32KnownVectors},
948
+ {"Crc32File", TestCrc32File},
949
+ {"ApplyPatchCopiesVerifyExpectedCrc", TestApplyPatchCopiesVerifyExpectedCrc},
781
950
  };
782
951
 
783
952
  int failures = 0;
@@ -27,6 +27,7 @@ set(LZMA_DIR ${ANDROID_JNI_DIR}/lzma)
27
27
  set(HDP_SOURCES
28
28
  ${CMAKE_CURRENT_SOURCE_DIR}/pushy.cpp
29
29
  ${PATCH_CORE_DIR}/archive_patch_core.cpp
30
+ ${PATCH_CORE_DIR}/digest.cpp
30
31
  ${PATCH_CORE_DIR}/hbc_transform.cpp
31
32
  ${PATCH_CORE_DIR}/hbc_transform_wire.cpp
32
33
  ${PATCH_CORE_DIR}/patch_core.cpp
@@ -6,6 +6,7 @@
6
6
  #include <vector>
7
7
 
8
8
  #include "archive_patch_core.h"
9
+ #include "digest.h"
9
10
  #include "hbc_transform_wire.h"
10
11
  #include "patch_core.h"
11
12
  #include "state_core.h"
@@ -890,6 +891,97 @@ bool ExportFunction(
890
891
  } // namespace
891
892
 
892
893
 
894
+ // sha256(Uint8Array | ArrayBuffer) -> 小写 hex。同步:bundleHash 的输入是
895
+ // rawfile 里的 bundle(已整体读入内存,与 pdiff 的读法一致),哈希本身毫秒级。
896
+ static napi_value Sha256Hex(napi_env env, napi_callback_info info) {
897
+ size_t argc = 1;
898
+ napi_value args[1] = {nullptr};
899
+ if (!GetArgCount(env, info, &argc, args) || argc < 1) {
900
+ ThrowError(env, "sha256Hex: missing data argument");
901
+ return nullptr;
902
+ }
903
+
904
+ void* data = nullptr;
905
+ size_t length = 0;
906
+ bool is_typedarray = false;
907
+ napi_is_typedarray(env, args[0], &is_typedarray);
908
+ if (is_typedarray) {
909
+ napi_typedarray_type type;
910
+ napi_value arraybuffer = nullptr;
911
+ size_t offset = 0;
912
+ if (napi_get_typedarray_info(
913
+ env, args[0], &type, &length, &data, &arraybuffer, &offset) !=
914
+ napi_ok) {
915
+ ThrowError(env, "sha256Hex: cannot read typed array");
916
+ return nullptr;
917
+ }
918
+ } else {
919
+ bool is_arraybuffer = false;
920
+ napi_is_arraybuffer(env, args[0], &is_arraybuffer);
921
+ if (!is_arraybuffer ||
922
+ napi_get_arraybuffer_info(env, args[0], &data, &length) != napi_ok) {
923
+ ThrowError(env, "sha256Hex: expected Uint8Array or ArrayBuffer");
924
+ return nullptr;
925
+ }
926
+ }
927
+
928
+ pushy::digest::Sha256 hasher;
929
+ if (length > 0) {
930
+ hasher.Update(static_cast<const uint8_t*>(data), length);
931
+ }
932
+ const std::string hex = hasher.HexDigest();
933
+
934
+ napi_value result = nullptr;
935
+ napi_create_string_utf8(env, hex.c_str(), hex.size(), &result);
936
+ return result;
937
+ }
938
+
939
+ // CRC32(zip/zlib 多项式)。同步:输入是已在内存的 rawfile 资源字节,用于
940
+ // pdiff 拷贝前与 __diff.json copiesCrc 比对(不符则整次 patch 失败落 full)。
941
+ static napi_value Crc32(napi_env env, napi_callback_info info) {
942
+ size_t argc = 1;
943
+ napi_value args[1] = {nullptr};
944
+ if (!GetArgCount(env, info, &argc, args) || argc < 1) {
945
+ ThrowError(env, "crc32: missing data argument");
946
+ return nullptr;
947
+ }
948
+
949
+ void* data = nullptr;
950
+ size_t length = 0;
951
+ bool is_typedarray = false;
952
+ napi_is_typedarray(env, args[0], &is_typedarray);
953
+ if (is_typedarray) {
954
+ napi_typedarray_type type;
955
+ napi_value arraybuffer = nullptr;
956
+ size_t offset = 0;
957
+ if (napi_get_typedarray_info(
958
+ env, args[0], &type, &length, &data, &arraybuffer, &offset) !=
959
+ napi_ok) {
960
+ ThrowError(env, "crc32: cannot read typed array");
961
+ return nullptr;
962
+ }
963
+ } else {
964
+ bool is_arraybuffer = false;
965
+ napi_is_arraybuffer(env, args[0], &is_arraybuffer);
966
+ if (!is_arraybuffer ||
967
+ napi_get_arraybuffer_info(env, args[0], &data, &length) != napi_ok) {
968
+ ThrowError(env, "crc32: expected Uint8Array or ArrayBuffer");
969
+ return nullptr;
970
+ }
971
+ }
972
+
973
+ pushy::digest::Crc32 crc;
974
+ if (length > 0) {
975
+ crc.Update(static_cast<const uint8_t*>(data), length);
976
+ }
977
+
978
+ napi_value result = nullptr;
979
+ // CRC32 fits a double exactly (32-bit unsigned), matching the JSON number
980
+ // representation in the manifest.
981
+ napi_create_double(env, static_cast<double>(crc.Value()), &result);
982
+ return result;
983
+ }
984
+
893
985
  // 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道)
894
986
  static napi_value GetSupportedDiffVersion(napi_env env, napi_callback_info) {
895
987
  napi_value result = nullptr;
@@ -907,6 +999,8 @@ napi_value Init(napi_env env, napi_value exports) {
907
999
  !ExportFunction(env, exports, "buildCopyGroups", BuildCopyGroups) ||
908
1000
  !ExportFunction(env, exports, "applyPatchFromFileSource", ApplyPatchFromFileSource) ||
909
1001
  !ExportFunction(env, exports, "cleanupOldEntries", CleanupOldEntries) ||
1002
+ !ExportFunction(env, exports, "sha256Hex", Sha256Hex) ||
1003
+ !ExportFunction(env, exports, "crc32", Crc32) ||
910
1004
  !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion)) {
911
1005
  return nullptr;
912
1006
  }
@@ -15,6 +15,10 @@ import NativePatchCore, {
15
15
  export interface PatchManifestArrays {
16
16
  copyFroms: string[];
17
17
  copyTos: string[];
18
+ // 与 copyFroms/copyTos 逐位对齐的 copiesCrc(CLI >= 2.21.2 的 pdiff
19
+ // manifest 携带,键是 to);无声明时为 null。拷贝前用它校验包内资源内容,
20
+ // 不符则整次 patch 失败落 full——重打包二进制不能静默拷出漂移的资源。
21
+ copyCrcs: (number | null)[];
18
22
  deletes: string[];
19
23
  // __diff.json 中对应 bundle patch 条目的 hbcTransform 元数据(原始 JSON
20
24
  // 字符串);为空时 native 走现状路径
@@ -27,6 +31,7 @@ export function parseManifestToArrays(
27
31
  ): PatchManifestArrays {
28
32
  const copyFroms: string[] = [];
29
33
  const copyTos: string[] = [];
34
+ const copyCrcs: (number | null)[] = [];
30
35
  const deletesValue = manifest.deletes;
31
36
  const deletes = Array.isArray(deletesValue)
32
37
  ? deletesValue.map(item => String(item))
@@ -35,6 +40,11 @@ export function parseManifestToArrays(
35
40
  : [];
36
41
 
37
42
  const copies = (manifest.copies || {}) as Record<string, string>;
43
+ const copiesCrcValue = manifest.copiesCrc;
44
+ const copiesCrc =
45
+ copiesCrcValue && typeof copiesCrcValue === 'object'
46
+ ? (copiesCrcValue as Record<string, number>)
47
+ : ({} as Record<string, number>);
38
48
  for (const [to, rawFrom] of Object.entries(copies)) {
39
49
  let from = String(rawFrom || '');
40
50
  if (normalizeResourceCopies) {
@@ -45,6 +55,8 @@ export function parseManifestToArrays(
45
55
  }
46
56
  copyFroms.push(from);
47
57
  copyTos.push(to);
58
+ const crc = copiesCrc[to];
59
+ copyCrcs.push(typeof crc === 'number' && Number.isFinite(crc) ? crc : null);
48
60
  }
49
61
 
50
62
  const hbcTransform = manifest.hbcTransform as
@@ -62,6 +74,7 @@ export function parseManifestToArrays(
62
74
  return {
63
75
  copyFroms,
64
76
  copyTos,
77
+ copyCrcs,
65
78
  deletes,
66
79
  hbcTransformMeta,
67
80
  };
@@ -229,6 +242,7 @@ export class DownloadTask {
229
242
  return {
230
243
  copyFroms: [],
231
244
  copyTos: [],
245
+ copyCrcs: [],
232
246
  deletes: [],
233
247
  hbcTransformMeta: '',
234
248
  };
@@ -534,6 +548,20 @@ export class DownloadTask {
534
548
 
535
549
  private async doPatchFromApp(params: DownloadTaskParams): Promise<void> {
536
550
  await this.downloadFile(params);
551
+ try {
552
+ await this.doPatchFromAppAfterDownload(params);
553
+ } catch (error: any) {
554
+ // 下载已完成,之后的失败都是 patch 应用失败(解压/hpatch/资源拷贝,
555
+ // 含 copiesCrc 校验)。打上稳定码,JS 层遥测按 patch_fail 归类,
556
+ // 而不是混进网络失败。
557
+ error.code = 'PATCH_FAILED';
558
+ throw error;
559
+ }
560
+ }
561
+
562
+ private async doPatchFromAppAfterDownload(
563
+ params: DownloadTaskParams,
564
+ ): Promise<void> {
537
565
  await this.recreateDirectory(params.unzipDirectory);
538
566
 
539
567
  await zlib.decompressFile(params.targetFile, params.unzipDirectory);
@@ -566,12 +594,22 @@ export class DownloadTask {
566
594
  `${params.unzipDirectory}/bundle.harmony.js`,
567
595
  manifestArrays.hbcTransformMeta,
568
596
  );
597
+ // 组按 from 聚合;同一 from 的内容必然一致,所以每组一个 CRC 即可
598
+ // (与 manifest 逐位对齐的 copyCrcs 里取第一个非空值)。
599
+ const crcByFrom = new Map<string, number>();
600
+ manifestArrays.copyFroms.forEach((from, index) => {
601
+ const crc = manifestArrays.copyCrcs[index];
602
+ if (crc !== null && !crcByFrom.has(from)) {
603
+ crcByFrom.set(from, crc);
604
+ }
605
+ });
569
606
  await this.copyFromResource(
570
607
  NativePatchCore.buildCopyGroups(
571
608
  manifestArrays.copyFroms,
572
609
  manifestArrays.copyTos,
573
610
  ),
574
611
  params.unzipDirectory,
612
+ crcByFrom,
575
613
  );
576
614
  try {
577
615
  if (fileIo.accessSync(params.targetFile)) {
@@ -584,6 +622,18 @@ export class DownloadTask {
584
622
 
585
623
  private async doPatchFromPpk(params: DownloadTaskParams): Promise<void> {
586
624
  await this.downloadFile(params);
625
+ try {
626
+ await this.doPatchFromPpkAfterDownload(params);
627
+ } catch (error: any) {
628
+ // 同 doPatchFromApp:下载完成后的失败按 patch 失败归类。
629
+ error.code = 'PATCH_FAILED';
630
+ throw error;
631
+ }
632
+ }
633
+
634
+ private async doPatchFromPpkAfterDownload(
635
+ params: DownloadTaskParams,
636
+ ): Promise<void> {
587
637
  await this.recreateDirectory(params.unzipDirectory);
588
638
 
589
639
  await zlib.decompressFile(params.targetFile, params.unzipDirectory);
@@ -626,6 +676,7 @@ export class DownloadTask {
626
676
  private async copyFromResource(
627
677
  copyGroups: CopyGroupResult[],
628
678
  targetRoot: string,
679
+ crcByFrom?: Map<string, number>,
629
680
  ): Promise<void> {
630
681
  let currentFrom = '';
631
682
  try {
@@ -637,6 +688,7 @@ export class DownloadTask {
637
688
  if (targets.length === 0) {
638
689
  continue;
639
690
  }
691
+ const expectedCrc = crcByFrom?.get(currentFrom);
640
692
 
641
693
  if (currentFrom.startsWith('resources/base/media/')) {
642
694
  // Strip only the final extension: 'icon.round.png' -> 'icon.round',
@@ -645,6 +697,7 @@ export class DownloadTask {
645
697
  .replace('resources/base/media/', '')
646
698
  .replace(/\.[^.]+$/, '');
647
699
  const mediaBuffer = await resourceManager.getMediaByName(mediaName);
700
+ this.verifyCopySourceCrc(mediaBuffer, expectedCrc, currentFrom);
648
701
  const parentDirs = [
649
702
  ...new Set(
650
703
  targets.map(t => t.substring(0, t.lastIndexOf('/'))).filter(Boolean),
@@ -658,6 +711,22 @@ export class DownloadTask {
658
711
  );
659
712
  continue;
660
713
  }
714
+
715
+ if (expectedCrc !== undefined) {
716
+ // 声明了 CRC 的条目改走字节路径:读出内容先校验再落盘。
717
+ // 重打包的二进制里同名 rawfile 内容可能已漂移,fd 直拷会把
718
+ // 错误字节静默装进版本目录。
719
+ const rawContent =
720
+ await resourceManager.getRawFileContent(currentFrom);
721
+ this.verifyCopySourceCrc(rawContent, expectedCrc, currentFrom);
722
+ const [firstTarget, ...restTargets] = targets;
723
+ await this.writeFileContent(firstTarget, rawContent);
724
+ await Promise.all(
725
+ restTargets.map(target => this.copySandboxFile(firstTarget, target)),
726
+ );
727
+ continue;
728
+ }
729
+
661
730
  const fromContent = await resourceManager.getRawFd(currentFrom);
662
731
  try {
663
732
  const [firstTarget, ...restTargets] = targets;
@@ -697,6 +766,22 @@ export class DownloadTask {
697
766
  }
698
767
  }
699
768
 
769
+ // pdiff 拷贝源内容校验(__diff.json copiesCrc):不符即抛错,整次 patch
770
+ // 失败后 JS 策略链自动回退 full,绝不把漂移的资源装进版本目录。
771
+ private verifyCopySourceCrc(
772
+ content: Uint8Array,
773
+ expectedCrc: number | undefined,
774
+ from: string,
775
+ ): void {
776
+ if (expectedCrc === undefined) {
777
+ return;
778
+ }
779
+ const actualCrc = NativePatchCore.crc32(content);
780
+ if (actualCrc !== expectedCrc) {
781
+ throw new Error(`resource content mismatch (crc32): ${from}`);
782
+ }
783
+ }
784
+
700
785
  private async doCleanUp(params: DownloadTaskParams): Promise<void> {
701
786
  try {
702
787
  await NativePatchCore.cleanupOldEntries(
@@ -80,6 +80,10 @@ interface NativePatchCoreBindings {
80
80
  keepPrevious: string,
81
81
  maxAgeDays: number,
82
82
  ): Promise<void>;
83
+ /** sha256(小写 hex)。同步:输入是已在内存的 rawfile bundle,哈希毫秒级 */
84
+ sha256Hex(data: Uint8Array): string;
85
+ /** CRC32(zip/zlib 多项式)。pdiff 拷贝前与 copiesCrc 比对用 */
86
+ crc32(data: Uint8Array | ArrayBuffer): number;
83
87
  /** 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道) */
84
88
  getSupportedDiffVersion(): number;
85
89
  }
@@ -233,6 +233,17 @@ export class PushyTurboModule extends UITurboModule {
233
233
  }
234
234
  }
235
235
 
236
+ async getBundleHash(): Promise<string> {
237
+ logger.debug(TAG, ',call getBundleHash');
238
+ try {
239
+ return this.context.getBundleHash();
240
+ } catch (error) {
241
+ // 空串 = "未知",服务端回退 buildTime 启发式;该方法永不 reject。
242
+ logger.error(TAG, `getBundleHash failed: ${getErrorMessage(error)}`);
243
+ return '';
244
+ }
245
+ }
246
+
236
247
  async resetToPackagedBundle(): Promise<void> {
237
248
  logger.debug(TAG, ',call resetToPackagedBundle');
238
249
  try {
@@ -20,6 +20,27 @@ type FlushablePreferences = preferences.Preferences & {
20
20
  flushSync?: () => void;
21
21
  };
22
22
 
23
+ // 服务端下发的 hash/originHash/fileName 会拼进 rootDir 作为子路径;凡是可能
24
+ // 逃出 rootDir 的值(路径分隔符、".."、".")必须在触碰文件系统前拒绝。
25
+ export function isSafePathComponent(name: string): boolean {
26
+ return (
27
+ typeof name === 'string' &&
28
+ name.length > 0 &&
29
+ name !== '.' &&
30
+ name !== '..' &&
31
+ !name.includes('/') &&
32
+ !name.includes('\\') &&
33
+ !name.includes('\0')
34
+ );
35
+ }
36
+
37
+ function assertSafePathComponent(name: string): string {
38
+ if (!isSafePathComponent(name)) {
39
+ throw Error(`Invalid path component: ${name}`);
40
+ }
41
+ return name;
42
+ }
43
+
23
44
  export class UpdateContext {
24
45
  private context: common.UIAbilityContext;
25
46
  private rootDir: string;
@@ -296,7 +317,7 @@ export class UpdateContext {
296
317
  const params = new DownloadTaskParams();
297
318
  params.type = type;
298
319
  params.url = url;
299
- params.hash = hash;
320
+ params.hash = assertSafePathComponent(hash);
300
321
  return params;
301
322
  }
302
323
 
@@ -474,7 +495,7 @@ export class UpdateContext {
474
495
  url,
475
496
  hash,
476
497
  );
477
- params.targetFile = this.rootDir + '/' + fileName;
498
+ params.targetFile = this.rootDir + '/' + assertSafePathComponent(fileName);
478
499
  await this.executeTask(params);
479
500
  }
480
501
 
@@ -488,7 +509,7 @@ export class UpdateContext {
488
509
  url,
489
510
  hash,
490
511
  );
491
- params.originHash = originHash;
512
+ params.originHash = assertSafePathComponent(originHash);
492
513
  params.targetFile = `${this.rootDir}/${originHash}_${hash}.ppk.patch`;
493
514
  params.unzipDirectory = `${this.rootDir}/${hash}`;
494
515
  params.originDirectory = `${this.rootDir}/${params.originHash}`;
@@ -516,7 +537,7 @@ export class UpdateContext {
516
537
 
517
538
  public switchVersion(hash: string): void {
518
539
  try {
519
- const bundlePath = this.getBundlePath(hash);
540
+ const bundlePath = this.getBundlePath(assertSafePathComponent(hash));
520
541
  if (!fileIo.accessSync(bundlePath)) {
521
542
  throw Error(`Bundle version ${hash} not found.`);
522
543
  }
@@ -632,4 +653,58 @@ export class UpdateContext {
632
653
  public getIsUsingBundleUrl(): boolean {
633
654
  return UpdateContext.isUsingBundleUrl;
634
655
  }
656
+
657
+ // bundleHash 缓存:"<packageVersion>|<updateTime>|<sha256hex>"。键标识当前
658
+ // 安装的二进制,每次(覆盖)安装 updateTime 都会变,每个安装只算一次。
659
+ private static readonly KEY_BUNDLE_HASH_CACHE = 'bundleHashCache';
660
+
661
+ private getBundleUpdateTime(): number {
662
+ try {
663
+ const bundleInfo = bundleManager.getBundleInfoForSelfSync(
664
+ this.getBundleFlags(),
665
+ );
666
+ return bundleInfo?.updateTime ?? 0;
667
+ } catch (error) {
668
+ console.error('Failed to get bundle update time:', error);
669
+ return 0;
670
+ }
671
+ }
672
+
673
+ /**
674
+ * bundleHash = 二进制内嵌 JS bundle 的 sha256 —— 标识二进制本身,与当前运行
675
+ * 的热更版本无关。哈希对象与 pdiff 的源读取完全一致:rawfile 的
676
+ * bundle.harmony.js(DownloadTask.doPatchFromApp 同一读法)。懒计算 + 缓存;
677
+ * 永不失败——空串表示"未知",服务端回退 buildTime 启发式。
678
+ */
679
+ public async getBundleHash(): Promise<string> {
680
+ if (UpdateContext.DEBUG) {
681
+ // debug 下 bundle 由 metro 提供,与 dev 删 buildTime 的行为对齐。
682
+ return '';
683
+ }
684
+ const cachePrefix = `${this.getPackageVersion()}|${this.getBundleUpdateTime()}|`;
685
+ const cached = this.readString(UpdateContext.KEY_BUNDLE_HASH_CACHE);
686
+ if (cached.startsWith(cachePrefix)) {
687
+ return cached.slice(cachePrefix.length);
688
+ }
689
+ let hash = '';
690
+ try {
691
+ // 异步读:TurboModule 跑在 UI 线程,同步读几 MB 会卡主线程一次。C++
692
+ // 哈希本身毫秒级,同步无妨。
693
+ const content =
694
+ await this.context.resourceManager.getRawFileContent('bundle.harmony.js');
695
+ hash = NativePatchCore.sha256Hex(content);
696
+ } catch (error) {
697
+ // rawfile 缺 bundle(未打包 release bundle)是合法的"未知",不是错误。
698
+ console.info('Cannot hash embedded bundle:', error);
699
+ return '';
700
+ }
701
+ if (hash) {
702
+ this.preferences.putSync(
703
+ UpdateContext.KEY_BUNDLE_HASH_CACHE,
704
+ cachePrefix + hash,
705
+ );
706
+ this.flushPreferences('cache bundle hash');
707
+ }
708
+ return hash;
709
+ }
635
710
  }
package/harmony/pushy.har CHANGED
Binary file