react-native-update 10.49.0 → 10.51.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 (61) hide show
  1. package/BUNDLEHASH_MIGRATION.md +22 -2
  2. package/NATIVE_CHECKUPDATE_DESIGN.md +251 -21
  3. package/NATIVE_CHECK_FOLLOWUPS.md +365 -0
  4. package/README-CN.md +14 -1
  5. package/README.md +14 -1
  6. package/android/jni/Android.mk +5 -1
  7. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  8. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  9. package/android/lib/x86/librnupdate.so +0 -0
  10. package/android/lib/x86_64/librnupdate.so +0 -0
  11. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +42 -9
  12. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +98 -22
  13. package/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +3 -0
  14. package/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +455 -0
  15. package/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +23 -0
  16. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
  17. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +196 -42
  18. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +56 -3
  19. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  20. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +10 -0
  21. package/cpp/patch_core/digest.cpp +53 -0
  22. package/cpp/patch_core/digest.h +18 -0
  23. package/cpp/patch_core/patch_core.cpp +17 -1
  24. package/cpp/patch_core/patch_core.h +8 -0
  25. package/cpp/patch_core/tests/patch_core_test.cpp +99 -0
  26. package/cpp/update_flow_core/flow_json.cpp +522 -0
  27. package/cpp/update_flow_core/flow_json.h +103 -0
  28. package/cpp/update_flow_core/tests/flow_vectors.json +1401 -0
  29. package/cpp/update_flow_core/tests/update_flow_core_test.cpp +317 -0
  30. package/cpp/update_flow_core/update_flow_core.cpp +301 -0
  31. package/cpp/update_flow_core/update_flow_core.h +77 -0
  32. package/cpp/update_flow_core/update_flow_jni.cpp +121 -0
  33. package/harmony/pushy/src/main/cpp/CMakeLists.txt +7 -0
  34. package/harmony/pushy/src/main/cpp/pushy.cpp +150 -1
  35. package/harmony/pushy/src/main/ets/DownloadTask.ts +133 -9
  36. package/harmony/pushy/src/main/ets/DownloadTaskParams.ts +3 -0
  37. package/harmony/pushy/src/main/ets/MonotonicClock.ts +13 -0
  38. package/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +500 -0
  39. package/harmony/pushy/src/main/ets/NativePatchCore.ts +16 -0
  40. package/harmony/pushy/src/main/ets/PathUtils.ts +21 -0
  41. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +22 -0
  42. package/harmony/pushy/src/main/ets/UpdateContext.ts +152 -52
  43. package/harmony/pushy.har +0 -0
  44. package/ios/RCTPushy/RCTPushy.mm +840 -68
  45. package/ios/RCTPushy/RCTPushyDownloader.h +1 -0
  46. package/ios/RCTPushy/RCTPushyDownloader.mm +4 -3
  47. package/package.json +3 -1
  48. package/react-native-update.podspec +2 -0
  49. package/src/NativePushy.ts +14 -0
  50. package/src/client.ts +439 -122
  51. package/src/endpoint.ts +105 -80
  52. package/src/error.ts +4 -0
  53. package/src/locales/en.ts +4 -0
  54. package/src/locales/zh.ts +4 -0
  55. package/src/provider.tsx +74 -9
  56. package/src/telemetry.ts +8 -0
  57. package/src/type.ts +31 -0
  58. package/src/updateFlowCore.ts +320 -0
  59. package/src/utils.ts +14 -12
  60. package/src/isInRollout.ts +0 -86
  61. package/src/resolveCheckResult.ts +0 -30
@@ -47,6 +47,17 @@ public class UpdateContext {
47
47
  private static final int STATE_OP_CLEAR_ROLLBACK_MARK = 5;
48
48
  private static final int STATE_OP_RESOLVE_LAUNCH = 6;
49
49
  private static final String KEY_FIRST_LOAD_MARKED = "firstLoadMarked";
50
+ static final String VERSION_COMPLETE_FILE = ".pushy-complete";
51
+ // Bumped by resetToPackagedBundle. The cold-start check runs for minutes
52
+ // and may already hold a decision when the app resets to the packaged
53
+ // bundle; the orchestrator samples this counter and abandons activation
54
+ // (and its response cache) when the value moved, so an in-flight rescue
55
+ // can never resurrect the version the app just reset away from.
56
+ private static final java.util.concurrent.atomic.AtomicLong resetGeneration =
57
+ new java.util.concurrent.atomic.AtomicLong(0);
58
+ // Held by resetToPackagedBundle and by the cold-start check's commit, so
59
+ // the generation check and the writes it guards are one atomic step.
60
+ private static final Object commitLock = new Object();
50
61
 
51
62
  // Singleton instance
52
63
  private static volatile UpdateContext sInstance;
@@ -159,7 +170,9 @@ public class UpdateContext {
159
170
  });
160
171
  }
161
172
 
162
- private String computeBundleHash() {
173
+ // Package-private: also the native cold-start check's request input
174
+ // (NativeCheckOrchestrator). Blocking — call off the main thread.
175
+ String computeBundleHash() {
163
176
  String cachePrefix = getPackageVersion() + "|" + getPackageLastUpdateTime() + "|";
164
177
  String cached = sp.getString(KEY_BUNDLE_HASH_CACHE, null);
165
178
  if (cached != null && cached.startsWith(cachePrefix)) {
@@ -211,23 +224,57 @@ public class UpdateContext {
211
224
  executor.execute(new DownloadTask(context, params));
212
225
  }
213
226
 
227
+ // Server-provided identifiers (hash/originHash/fileName) become child
228
+ // names under rootDir; anything that could resolve outside of it (path
229
+ // separators, "..", ".") must be rejected before touching the filesystem.
230
+ static boolean isSafePathComponent(String name) {
231
+ return name != null
232
+ && !name.isEmpty()
233
+ && !name.equals(".")
234
+ && !name.equals("..")
235
+ && !name.contains("/")
236
+ && !name.contains("\\")
237
+ && name.indexOf('\0') < 0;
238
+ }
239
+
240
+ private static boolean rejectUnsafeComponent(String name, DownloadFileListener listener) {
241
+ if (isSafePathComponent(name)) {
242
+ return false;
243
+ }
244
+ listener.onDownloadFailed(new IllegalArgumentException("Invalid path component: " + name));
245
+ return true;
246
+ }
247
+
214
248
  public interface DownloadFileListener {
215
249
  void onDownloadCompleted(DownloadTaskParams params);
216
250
  void onDownloadFailed(Throwable error);
217
251
  }
218
252
 
219
253
  public void downloadFullUpdate(String url, String hash, DownloadFileListener listener) {
254
+ downloadFullUpdate(url, hash, listener, 0);
255
+ }
256
+
257
+ void downloadFullUpdate(
258
+ String url, String hash, DownloadFileListener listener, long deadlineNanos
259
+ ) {
260
+ if (rejectUnsafeComponent(hash, listener)) {
261
+ return;
262
+ }
220
263
  DownloadTaskParams params = new DownloadTaskParams();
221
264
  params.type = DownloadTaskParams.TASK_TYPE_PATCH_FULL;
222
265
  params.url = url;
223
266
  params.hash = hash;
224
267
  params.listener = listener;
268
+ params.deadlineNanos = deadlineNanos;
225
269
  params.targetFile = new File(rootDir, hash + ".ppk");
226
270
  params.unzipDirectory = new File(rootDir, hash);
227
271
  enqueue(params);
228
272
  }
229
273
 
230
274
  public void downloadFile(String url, String hash, String fileName, DownloadFileListener listener) {
275
+ if (rejectUnsafeComponent(fileName, listener)) {
276
+ return;
277
+ }
231
278
  DownloadTaskParams params = new DownloadTaskParams();
232
279
  params.type = DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD;
233
280
  params.url = url;
@@ -246,23 +293,47 @@ public class UpdateContext {
246
293
  }
247
294
 
248
295
  public void downloadPatchFromApk(String url, String hash, DownloadFileListener listener) {
296
+ downloadPatchFromApk(url, hash, listener, 0);
297
+ }
298
+
299
+ void downloadPatchFromApk(
300
+ String url, String hash, DownloadFileListener listener, long deadlineNanos
301
+ ) {
302
+ if (rejectUnsafeComponent(hash, listener)) {
303
+ return;
304
+ }
249
305
  DownloadTaskParams params = new DownloadTaskParams();
250
306
  params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK;
251
307
  params.url = url;
252
308
  params.hash = hash;
253
309
  params.listener = listener;
310
+ params.deadlineNanos = deadlineNanos;
254
311
  params.targetFile = new File(rootDir, hash + ".apk.patch");
255
312
  params.unzipDirectory = new File(rootDir, hash);
256
313
  enqueue(params);
257
314
  }
258
315
 
259
316
  public void downloadPatchFromPpk(String url, String hash, String originHash, DownloadFileListener listener) {
317
+ downloadPatchFromPpk(url, hash, originHash, listener, 0);
318
+ }
319
+
320
+ void downloadPatchFromPpk(
321
+ String url,
322
+ String hash,
323
+ String originHash,
324
+ DownloadFileListener listener,
325
+ long deadlineNanos
326
+ ) {
327
+ if (rejectUnsafeComponent(hash, listener) || rejectUnsafeComponent(originHash, listener)) {
328
+ return;
329
+ }
260
330
  DownloadTaskParams params = new DownloadTaskParams();
261
331
  params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK;
262
332
  params.url = url;
263
333
  params.hash = hash;
264
334
  params.originHash = originHash;
265
335
  params.listener = listener;
336
+ params.deadlineNanos = deadlineNanos;
266
337
  params.targetFile = new File(rootDir, originHash + "-" + hash + ".ppk.patch");
267
338
  params.unzipDirectory = new File(rootDir, hash);
268
339
  params.originDirectory = new File(rootDir, originHash);
@@ -312,10 +383,24 @@ public class UpdateContext {
312
383
  }
313
384
 
314
385
  public void switchVersion(String hash) {
315
- if (!new File(rootDir, hash+"/index.bundlejs").exists()) {
386
+ if (!isSafePathComponent(hash)) {
387
+ throw new IllegalArgumentException("Invalid hash: " + hash);
388
+ }
389
+ File versionDir = new File(rootDir, hash);
390
+ File bundleFile = new File(versionDir, "index.bundlejs");
391
+ if (!bundleFile.isFile()) {
316
392
  throw new IllegalStateException("Bundle version " + hash + " not found.");
317
393
  }
318
394
  StateCoreResult currentState = getStateSnapshot();
395
+ boolean isLegacyActivatedVersion = hash.equals(currentState.currentVersion)
396
+ || hash.equals(currentState.lastVersion);
397
+ if (!new File(versionDir, VERSION_COMPLETE_FILE).isFile()
398
+ && !isLegacyActivatedVersion) {
399
+ // Versions activated before completion markers were introduced are
400
+ // explicitly grandfathered through current/last state. An arbitrary
401
+ // markerless directory may be a crash-left partial install.
402
+ throw new IllegalStateException("Bundle version " + hash + " is incomplete.");
403
+ }
319
404
  StateCoreResult nextState = runStateCore(
320
405
  STATE_OP_SWITCH_VERSION,
321
406
  currentState,
@@ -409,6 +494,16 @@ public class UpdateContext {
409
494
  * for gray release bucketing and must not change on reset.
410
495
  */
411
496
  public void resetToPackagedBundle() {
497
+ synchronized (commitLock) {
498
+ resetToPackagedBundleLocked();
499
+ }
500
+ }
501
+
502
+ private void resetToPackagedBundleLocked() {
503
+ // Invalidate any in-flight cold-start round before clearing state: a
504
+ // round committing under the same lock afterwards sees the new
505
+ // generation and drops its result.
506
+ resetGeneration.incrementAndGet();
412
507
  StateCoreResult resetState = new StateCoreResult();
413
508
  resetState.packageVersion = getPackageVersion();
414
509
  resetState.buildTime = getBuildTime();
@@ -423,6 +518,8 @@ public class UpdateContext {
423
518
  }
424
519
  persistEditor(editor, "reset to packaged bundle");
425
520
  ignoreRollback = false;
521
+ // editor.clear() above already dropped the cached check response; it
522
+ // still advertised the version this reset removed.
426
523
  Log.i(TAG, "Reset to packaged bundle");
427
524
 
428
525
  DownloadTaskParams params = new DownloadTaskParams();
@@ -494,55 +591,112 @@ public class UpdateContext {
494
591
 
495
592
  public String getBundleUrl(String defaultAssetsUrl) {
496
593
  isUsingBundleUrl = true;
497
- StateCoreResult currentState = getStateSnapshot();
498
- StateCoreResult launchState = runStateCore(
499
- STATE_OP_RESOLVE_LAUNCH,
500
- currentState,
501
- null,
502
- ignoreRollback,
503
- true
504
- );
505
- if (launchState.didRollback) {
506
- // The crash-protection rollback: the new version never called
507
- // markSuccess. Keep this visible in release logs.
508
- Log.e(TAG, "Version " + currentState.currentVersion
509
- + " was not marked as successful, rolling back to "
510
- + launchState.currentVersion);
511
- }
512
- if (launchState.didRollback || launchState.consumedFirstTime) {
513
- SharedPreferences.Editor editor = sp.edit();
514
- applyState(editor, launchState);
594
+ String nativeCheckRolledBackVersion = null;
595
+ try {
596
+ StateCoreResult currentState = getStateSnapshot();
597
+ StateCoreResult launchState = runStateCore(
598
+ STATE_OP_RESOLVE_LAUNCH,
599
+ currentState,
600
+ null,
601
+ ignoreRollback,
602
+ true
603
+ );
604
+ nativeCheckRolledBackVersion = launchState.rolledBackVersion;
605
+ if (launchState.didRollback) {
606
+ // The crash-protection rollback: the new version never called
607
+ // markSuccess. Keep this visible in release logs.
608
+ Log.e(TAG, "Version " + currentState.currentVersion
609
+ + " was not marked as successful, rolling back to "
610
+ + launchState.currentVersion);
611
+ }
612
+ if (launchState.didRollback || launchState.consumedFirstTime) {
613
+ SharedPreferences.Editor editor = sp.edit();
614
+ applyState(editor, launchState);
615
+ if (launchState.consumedFirstTime) {
616
+ editor.putBoolean(KEY_FIRST_LOAD_MARKED, true);
617
+ }
618
+ persistEditor(editor, "resolve launch");
619
+ }
515
620
  if (launchState.consumedFirstTime) {
516
- editor.putBoolean(KEY_FIRST_LOAD_MARKED, true);
621
+ // bundleURL may be resolved multiple times in one process.
622
+ ignoreRollback = true;
623
+ }
624
+
625
+ String currentVersion = launchState.loadVersion;
626
+ if (currentVersion == null) {
627
+ return defaultAssetsUrl;
517
628
  }
518
- persistEditor(editor, "resolve launch");
519
- }
520
- if (launchState.consumedFirstTime) {
521
- // bundleURL may be resolved multiple times in one process.
522
- ignoreRollback = true;
523
- }
524
629
 
525
- String currentVersion = launchState.loadVersion;
526
- if (currentVersion == null) {
630
+ // Guard the rollback chain against cycles: a corrupted state returning
631
+ // an already-visited version would otherwise spin this loop forever on
632
+ // the main thread.
633
+ java.util.HashSet<String> visitedVersions = new java.util.HashSet<>();
634
+ while (currentVersion != null && visitedVersions.add(currentVersion)) {
635
+ File bundleFile = new File(rootDir, currentVersion+"/index.bundlejs");
636
+ if (!bundleFile.exists()) {
637
+ Log.e(TAG, "Bundle version " + currentVersion + " not found.");
638
+ currentVersion = this.rollBack();
639
+ nativeCheckRolledBackVersion = rolledBackVersion();
640
+ continue;
641
+ }
642
+ launchVersion = currentVersion;
643
+ nativeCheckRolledBackVersion = rolledBackVersion();
644
+ return bundleFile.toString();
645
+ }
646
+
647
+ nativeCheckRolledBackVersion = rolledBackVersion();
527
648
  return defaultAssetsUrl;
649
+ } finally {
650
+ // Even corrupted state or a state-core exception must not disable
651
+ // the next-launch rescue check. A null snapshot simply omits the
652
+ // rollback guard for this exceptional launch.
653
+ NativeCheckOrchestrator.schedule(this, nativeCheckRolledBackVersion);
528
654
  }
655
+ }
529
656
 
530
- // Guard the rollback chain against cycles: a corrupted state returning
531
- // an already-visited version would otherwise spin this loop forever on
532
- // the main thread.
533
- java.util.HashSet<String> visitedVersions = new java.util.HashSet<>();
534
- while (currentVersion != null && visitedVersions.add(currentVersion)) {
535
- File bundleFile = new File(rootDir, currentVersion+"/index.bundlejs");
536
- if (!bundleFile.exists()) {
537
- Log.e(TAG, "Bundle version " + currentVersion + " not found.");
538
- currentVersion = this.rollBack();
539
- continue;
657
+ /** Sampled/compared by the native check orchestrator; see resetGeneration. */
658
+ static long getResetGeneration() {
659
+ return resetGeneration.get();
660
+ }
661
+
662
+ /**
663
+ * Commit everything a cold-start round persists — version info, the
664
+ * activation, the response cache — under one lock that first re-checks the
665
+ * reset generation. resetToPackagedBundle takes the same lock, so there is
666
+ * no compare-and-act window: either the whole round lands, or the reset
667
+ * wins and none of it does. Returns whether the round was committed.
668
+ */
669
+ boolean commitNativeCheckResult(
670
+ long expectedGeneration,
671
+ String hash,
672
+ String hashInfoJson,
673
+ boolean activate,
674
+ String responseCacheJson
675
+ ) {
676
+ synchronized (commitLock) {
677
+ if (resetGeneration.get() != expectedGeneration) {
678
+ return false;
679
+ }
680
+ if (hash != null && hashInfoJson != null) {
681
+ setKv("hash_" + hash, hashInfoJson);
682
+ }
683
+ if (activate && hash != null) {
684
+ switchVersion(hash);
540
685
  }
541
- launchVersion = currentVersion;
542
- return bundleFile.toString();
686
+ if (responseCacheJson != null) {
687
+ setKv(NativeCheckOrchestrator.KEY_RESP_CACHE, responseCacheJson);
688
+ }
689
+ return true;
543
690
  }
691
+ }
544
692
 
545
- return defaultAssetsUrl;
693
+ boolean hasCompletedVersion(String hash) {
694
+ if (!isSafePathComponent(hash)) {
695
+ return false;
696
+ }
697
+ File versionDir = new File(rootDir, hash);
698
+ return new File(versionDir, "index.bundlejs").isFile()
699
+ && new File(versionDir, VERSION_COMPLETE_FILE).isFile();
546
700
  }
547
701
 
548
702
  private String rollBack() {
@@ -41,11 +41,20 @@ public class UpdateModuleImpl {
41
41
 
42
42
  @Override
43
43
  public void onDownloadFailed(Throwable error) {
44
- promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
44
+ promise.reject(downloadErrorCode(error), error);
45
45
  }
46
46
  });
47
47
  }
48
48
 
49
+ // Post-download failures (unzip / hdiff / resource copy, incl. copiesCrc
50
+ // verification) reject as PATCH_FAILED so JS-side telemetry can separate
51
+ // patch health from network health. DownloadTask wraps them.
52
+ private static String downloadErrorCode(Throwable error) {
53
+ return error instanceof PatchFailedException
54
+ ? ErrorCodes.PATCH_FAILED
55
+ : ErrorCodes.DOWNLOAD_FAILED;
56
+ }
57
+
49
58
  public static void downloadAndInstallApk(
50
59
  final ReactApplicationContext reactContext,
51
60
  UpdateContext updateContext,
@@ -84,7 +93,7 @@ public class UpdateModuleImpl {
84
93
 
85
94
  @Override
86
95
  public void onDownloadFailed(Throwable error) {
87
- promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
96
+ promise.reject(downloadErrorCode(error), error);
88
97
  }
89
98
  });
90
99
  }
@@ -107,7 +116,7 @@ public class UpdateModuleImpl {
107
116
 
108
117
  @Override
109
118
  public void onDownloadFailed(Throwable error) {
110
- promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
119
+ promise.reject(downloadErrorCode(error), error);
111
120
  }
112
121
  });
113
122
  } catch (Exception e) {
@@ -215,10 +224,54 @@ public class UpdateModuleImpl {
215
224
  });
216
225
  }
217
226
 
227
+ /**
228
+ * Raw response cached by the native cold-start check, for the JS side to
229
+ * reuse instead of re-checking (§10.3). Empty string when absent; never
230
+ * rejects.
231
+ */
232
+ public static void getNativeCheckCache(
233
+ final UpdateContext updateContext,
234
+ final Promise promise
235
+ ) {
236
+ String cached = updateContext.getKv(NativeCheckOrchestrator.KEY_RESP_CACHE);
237
+ promise.resolve(cached == null ? "" : cached);
238
+ }
239
+
218
240
  private static void setUuidInternal(UpdateContext updateContext, String uuid) {
219
241
  updateContext.setKv("uuid", uuid);
220
242
  }
221
243
 
244
+ /**
245
+ * Provisioning for the native cold-start update check
246
+ * (NATIVE_CHECKUPDATE_DESIGN §10.1): the raw JSON persists as-is and is
247
+ * parsed on read by the orchestrator; absent config = check disabled.
248
+ * Validated at write time — a corrupt config would otherwise silently
249
+ * disable the native check forever with no signal.
250
+ */
251
+ public static void syncNativeConfig(
252
+ final UpdateContext updateContext,
253
+ final String config,
254
+ final Promise promise
255
+ ) {
256
+ if (config == null || config.isEmpty()) {
257
+ promise.reject(ErrorCodes.INVALID_OPTIONS, "config must be a JSON object string");
258
+ return;
259
+ }
260
+ try {
261
+ new JSONObject(config);
262
+ } catch (JSONException e) {
263
+ promise.reject(ErrorCodes.INVALID_OPTIONS, "config must be a JSON object string", e);
264
+ return;
265
+ }
266
+ StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "syncNativeConfig", new StateSerialRunner.Operation() {
267
+ @Override
268
+ public void run() {
269
+ updateContext.setKv(NativeCheckOrchestrator.KEY_CONFIG, config);
270
+ promise.resolve(true);
271
+ }
272
+ });
273
+ }
274
+
222
275
  public static void setUuid(
223
276
  final UpdateContext updateContext,
224
277
  final String uuid,
@@ -88,6 +88,16 @@ public class UpdateModule extends NativePushySpec {
88
88
  UpdateModuleImpl.setUuid(updateContext, uuid, promise);
89
89
  }
90
90
 
91
+ @Override
92
+ public void syncNativeConfig(String config, Promise promise) {
93
+ UpdateModuleImpl.syncNativeConfig(updateContext, config, promise);
94
+ }
95
+
96
+ @Override
97
+ public void getNativeCheckCache(Promise promise) {
98
+ UpdateModuleImpl.getNativeCheckCache(updateContext, promise);
99
+ }
100
+
91
101
  @Override
92
102
  public void setLocalHashInfo(String hash, String info, Promise promise) {
93
103
  UpdateModuleImpl.setLocalHashInfo(updateContext, hash, info, promise);
@@ -90,6 +90,16 @@ public class UpdateModule extends ReactContextBaseJavaModule {
90
90
  UpdateModuleImpl.setUuid(updateContext, uuid);
91
91
  }
92
92
 
93
+ @ReactMethod
94
+ public void syncNativeConfig(String config, Promise promise) {
95
+ UpdateModuleImpl.syncNativeConfig(updateContext, config, promise);
96
+ }
97
+
98
+ @ReactMethod
99
+ public void getNativeCheckCache(Promise promise) {
100
+ UpdateModuleImpl.getNativeCheckCache(updateContext, promise);
101
+ }
102
+
93
103
  @ReactMethod
94
104
  public void setLocalHashInfo(String hash, String info) {
95
105
  UpdateModuleImpl.setLocalHashInfo(updateContext, hash, info);
@@ -173,5 +173,58 @@ std::string Sha256File(const std::string& path) {
173
173
  return hasher.HexDigest();
174
174
  }
175
175
 
176
+ namespace {
177
+
178
+ struct Crc32TableHolder {
179
+ uint32_t table[256];
180
+
181
+ Crc32TableHolder() {
182
+ for (uint32_t i = 0; i < 256; i++) {
183
+ uint32_t value = i;
184
+ for (int bit = 0; bit < 8; bit++) {
185
+ value = (value & 1u) ? (value >> 1) ^ 0xEDB88320u : value >> 1;
186
+ }
187
+ table[i] = value;
188
+ }
189
+ }
190
+ };
191
+
192
+ const uint32_t* Crc32Table() {
193
+ // Function-local static: thread-safe one-time construction (C++11).
194
+ static const Crc32TableHolder holder;
195
+ return holder.table;
196
+ }
197
+
198
+ } // namespace
199
+
200
+ void Crc32::Update(const uint8_t* data, size_t length) {
201
+ const uint32_t* table = Crc32Table();
202
+ uint32_t state = state_;
203
+ for (size_t i = 0; i < length; i++) {
204
+ state = table[(state ^ data[i]) & 0xFFu] ^ (state >> 8);
205
+ }
206
+ state_ = state;
207
+ }
208
+
209
+ bool Crc32File(const std::string& path, uint32_t* out) {
210
+ std::FILE* file = std::fopen(path.c_str(), "rb");
211
+ if (file == nullptr) {
212
+ return false;
213
+ }
214
+ Crc32 crc;
215
+ uint8_t buffer[64 * 1024];
216
+ size_t read = 0;
217
+ while ((read = std::fread(buffer, 1, sizeof(buffer), file)) > 0) {
218
+ crc.Update(buffer, read);
219
+ }
220
+ const bool failed = std::ferror(file) != 0;
221
+ std::fclose(file);
222
+ if (failed) {
223
+ return false;
224
+ }
225
+ *out = crc.Value();
226
+ return true;
227
+ }
228
+
176
229
  } // namespace digest
177
230
  } // namespace pushy
@@ -39,5 +39,23 @@ class Sha256 {
39
39
  // digest, or an empty string if the file cannot be read.
40
40
  std::string Sha256File(const std::string& path);
41
41
 
42
+ // Streaming CRC32 (IEEE 802.3, reflected polynomial 0xEDB88320 — the zip /
43
+ // zlib checksum). Used to verify pdiff copy sources against the CRCs the CLI
44
+ // records from the origin package's zip entries (copiesCrc in __diff.json):
45
+ // the values must be byte-compatible with what any zip tool would report.
46
+ class Crc32 {
47
+ public:
48
+ void Update(const uint8_t* data, size_t length);
49
+
50
+ uint32_t Value() const { return state_ ^ 0xFFFFFFFFu; }
51
+
52
+ private:
53
+ uint32_t state_ = 0xFFFFFFFFu;
54
+ };
55
+
56
+ // CRC32 of a file on disk (streaming, bounded memory). Returns false if the
57
+ // file cannot be read.
58
+ bool Crc32File(const std::string& path, uint32_t* out);
59
+
42
60
  } // namespace digest
43
61
  } // namespace pushy
@@ -1,5 +1,7 @@
1
1
  #include "patch_core.h"
2
2
 
3
+ #include "digest.h"
4
+
3
5
  #include <cerrno>
4
6
  #include <cstdio>
5
7
  #include <cstring>
@@ -532,8 +534,22 @@ Status ApplyPatchFromFileSource(
532
534
  }
533
535
 
534
536
  for (const CopyOperation& copy : options.manifest.copies) {
537
+ const std::string source_path = JoinPath(options.source_root, copy.from);
538
+ if (copy.has_expected_crc) {
539
+ uint32_t actual_crc = 0;
540
+ if (!digest::Crc32File(source_path, &actual_crc)) {
541
+ return Status::Error("Failed to read copy source " + source_path);
542
+ }
543
+ if (actual_crc != copy.expected_crc) {
544
+ // Rebuilt binary with drifted content at an unchanged path. Fail the
545
+ // patch — the caller falls back to the full package; never install a
546
+ // resource whose bytes differ from what the diff was generated against.
547
+ return Status::Error(
548
+ "Copy source content mismatch (crc32): " + copy.from);
549
+ }
550
+ }
535
551
  Status copy_status = CopyFile(
536
- JoinPath(options.source_root, copy.from),
552
+ source_path,
537
553
  JoinPath(options.target_root, copy.to),
538
554
  true);
539
555
  if (!copy_status) {
@@ -1,5 +1,6 @@
1
1
  #pragma once
2
2
 
3
+ #include <cstdint>
3
4
  #include <ctime>
4
5
  #include <string>
5
6
  #include <vector>
@@ -20,6 +21,13 @@ struct Status {
20
21
  struct CopyOperation {
21
22
  std::string from;
22
23
  std::string to;
24
+ // Manifest-declared CRC32 of the source content (__diff.json copiesCrc,
25
+ // present in pdiff manifests from CLI >= 2.21.2). When set, the copy source
26
+ // is verified before copying; a mismatch fails the whole patch so the JS
27
+ // strategy chain falls back to the full package instead of installing a
28
+ // wrong resource from a rebuilt binary.
29
+ bool has_expected_crc = false;
30
+ uint32_t expected_crc = 0;
23
31
  };
24
32
 
25
33
  struct PatchManifest {