react-native-update 10.49.0 → 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 (32) hide show
  1. package/BUNDLEHASH_MIGRATION.md +22 -2
  2. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  3. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  4. package/android/lib/x86/librnupdate.so +0 -0
  5. package/android/lib/x86_64/librnupdate.so +0 -0
  6. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +42 -9
  7. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +22 -1
  8. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
  9. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +36 -0
  10. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +12 -3
  11. package/cpp/patch_core/digest.cpp +53 -0
  12. package/cpp/patch_core/digest.h +18 -0
  13. package/cpp/patch_core/patch_core.cpp +17 -1
  14. package/cpp/patch_core/patch_core.h +8 -0
  15. package/cpp/patch_core/tests/patch_core_test.cpp +99 -0
  16. package/harmony/pushy/src/main/cpp/pushy.cpp +47 -0
  17. package/harmony/pushy/src/main/ets/DownloadTask.ts +85 -0
  18. package/harmony/pushy/src/main/ets/NativePatchCore.ts +2 -0
  19. package/harmony/pushy/src/main/ets/UpdateContext.ts +25 -4
  20. package/harmony/pushy.har +0 -0
  21. package/ios/RCTPushy/RCTPushy.mm +61 -7
  22. package/package.json +1 -1
  23. package/src/client.ts +98 -4
  24. package/src/endpoint.ts +98 -51
  25. package/src/error.ts +4 -0
  26. package/src/isInRollout.ts +2 -1
  27. package/src/locales/en.ts +4 -0
  28. package/src/locales/zh.ts +4 -0
  29. package/src/provider.tsx +36 -7
  30. package/src/telemetry.ts +8 -0
  31. package/src/type.ts +5 -0
  32. package/src/utils.ts +13 -0
@@ -4,7 +4,9 @@
4
4
  > - DB:pushy 北京 RDS 与 cresc Cloud SQL 均已加 `packages.bundleHash` 列(幂等 runner 在 `scripts/migrate-add-package-bundle-hash.ts`;cresc 经一次性 Cloud Run Job 进 VPC 执行)
5
5
  > - 服务:pushy `p`/`s1` @ `2026.7.29-a2ecac26`,cresc api/worker/dailyjob @ `2026.7.29-3aabff91`,线上带 `bundleHash` 冒烟均通过
6
6
  > - **`bundleHashJudge.enabled` 仍为关**——待 CLI/客户端发版、覆盖率起来后再开
7
- > - 客户端与 CLI 已提交**未发版**(npm 发布是独立决策);Phase 2 遗留见 §4.5,Phase 3 待观察期后
7
+ > - **客户端与 CLI 已发版(2026-07-30)**:`react-native-update@10.49.0`、`react-native-update-cli@2.20.3`。全链路(客户端上报 → CLI 注册 → 服务端判定基建)就位,仅待覆盖率与开开关
8
+ > - **`bundleHashJudge.enabled` 已于 2026-08-03 双端开启**(pushy + cresc,redis `config_dynamic`,回退=写回 false)。同日:CLI 2.21.2 补全 copiesCrc(§4.2.1 第 2 条)并随两服务端部署;客户端三端 CRC 校验 + fallback 遥测(§4.2.1 第 3/4 条)已实现、待随下个客户端版本发布
9
+ > - Phase 2 遗留见 §4.5,Phase 3 待观察期后
8
10
  > 依据:`BUNDLEHASH_DESIGN.md`(设计定稿)。本文只讲**怎么落地**,不重复论证为什么。
9
11
  > 下游:`NATIVE_CHECKUPDATE_DESIGN.md` 的协议下沉必须等本迁移完成,否则协议下沉后再改协议要返工。
10
12
 
@@ -203,6 +205,12 @@ await addFileFromZipEntry(...); // 只有新文件才打进 patch 包
203
205
  3. 客户端拷贝时校验,不符 → fallback full。代价退化为一次 pdiff 下载,**永不装出坏包**
204
206
  4. 给该 fallback 加遥测
205
207
 
208
+ **已全部落地(2026-08-03)**:
209
+ - 第 2 条:CLI 2.21.2(同路径分支补记 CRC;两服务端已 bump 部署,worker 新生成的 pdiff 均带全量 copiesCrc)
210
+ - 第 3 条(三端):Android `BundledResourceCopier` 路径命中也比 CRC,不符 → 内容定位 → 仍无 → 抛错;iOS/共享 C++ `CopyOperation.expected_crc` 在 `ApplyPatchFromFileSource` 拷贝前校验(`digest::Crc32File`,NIST/check-value 测试锚定);Harmony pdiff 拷贝声明 CRC 的条目改走字节路径经 NAPI `crc32` 校验。三端不符均使整次 pdiff 失败,JS 策略链自动落 full
211
+ - 第 4 条:新增本地事件 `downloadFallback`(增量策略失败但后续策略救回),失败码为 PATCH_FAILED 时映射服务端 `patch_fail`(网络噪音不上报)。为此补齐了 Android(`PatchFailedException`:下载完成后的失败 reject 为 PATCH_FAILED,此前一律 DOWNLOAD_FAILED)与 Harmony(patch 阶段 `error.code='PATCH_FAILED'`)的稳定码
212
+ - 注意:存量已生成的 pdiff 只有 moved 条目的 CRC,客户端只校验有声明的条目——旧 pdiff 行为不变,随版本滚动自然收敛
213
+
206
214
  若遥测显示这一行的 pdiff 失败率显著,再引入**精确**判据(如"copies 含非 metro 资源"),而不是用恒为 true 的粗标记一刀切。
207
215
 
208
216
  ### 4.3 开关与灰度
@@ -236,7 +244,7 @@ cresc-server 是独立仓库(结构同构、历史独立,`handleCheckUpdate`
236
244
 
237
245
  ### 4.5 Phase 2 遗留(不阻塞开启开关)
238
246
 
239
- - 控制台 unknownBundle 聚合面板(§7 触达第 3 条,主渠道)——需 admin 侧配合。曾考虑挂在 `classifyCheckUpdateResult` 的 hitType 上,否决:hitType 是"下发了什么产物"维度,混入二进制身份维度不干净,应独立埋点
247
+ - ~~控制台 unknownBundle 聚合面板~~ **已实现(2026-08-01,四端部署)**:最终形态不是独立的 bundleStatus"内容判定"维度(首版上线后即废弃——它依赖开关开启+双侧 hash+恰好有更新流动,观测窗口太窄、零数据),而是**请求侧合并进原生包面板**:客户端报了 bundleHash 就记 `packageVersion_bundleHash`(admin 图例把 64 位 hex 截 8 位展示),否则回退 `packageVersion_buildTime`,同一 attribute。全量流量可见、与开关/判定结果解耦;未注册人群在面板上直接带 packageVersion 上下文。代价是丢失 matched/rebuiltSameJs 区分——那只有教育用途,unknownBundle 占比(§4.6 的推进指标)不受影响。曾考虑挂在 `classifyCheckUpdateResult` 的 hitType 上,否决:hitType 是"下发了什么产物"维度,混入二进制身份维度不干净
240
248
  - `digests` 下发 + `__diff.json` copies 附摘要——与客户端 Phase 3 的消费侧同波次做
241
249
  - ~~CLI 上传冲突提示~~ **已实现(改判为无需动 CLI)**:`(appId, name)` 唯一约束下重复上传本来就 409,在两台服务器的冲突分支里用 bundleHash 增强语义——内容一致 →"无需重复上传"、内容不同 →"重打包且 JS 有变,请提升原生版本号"、任一侧无 hash → 通用文案不变。查询仅发生在冲突路径。cresc 3 条路由级用例覆盖;pushy 无路由级测试脚手架,靠同构代码 + cresc 用例 + tsc 兜底
242
250
 
@@ -252,6 +260,18 @@ Phase 2 的收益与 Phase 0/1 的覆盖率成正比,但**不需要等覆盖
252
260
 
253
261
  ## 5. Phase 3 — 收尾(客户端)
254
262
 
263
+ > **定位修订(2026-08-03):buildTime 不删,永久保留作 fallback。**
264
+ > 理由:服务端分支与客户端双上报的保留成本≈0,而删除的伤害面(bundleHash
265
+ > 空串设备失去二进制身份 → 重打包检测失明 / 双缺请求拿不到 full)明确存在
266
+ > 且只能靠空串率数据压低,收益(构建注入清理、协议更干净)不急迫。注入的
267
+ > pnpm 老毛病若再发作,修注入本身而不是删身份。
268
+ > Phase 3 因此收缩为一件事:**§5.1 的 SyncBinaryVersion 迁移**——改为
269
+ > `bundleHash || buildTime`(bundleHash 优先、buildTime 兜底,schema 认领式
270
+ > 迁移照做)。不做它,rebuiltSameJs 的收益只兑现一半:服务端已不 block、照发
271
+ > pdiff,但客户端 buildTime 一变仍清状态回内置包再白下载一遍;hash 优先后
272
+ > 同 JS 重打包做到真正无感。§5.2 的 digests/copies 摘要照旧待做;§5.3 的
273
+ > 降级序列与"删构建注入"**取消**。
274
+
255
275
  ### 5.1 `SyncBinaryVersion` 迁移有个状态危险,必须处理
256
276
 
257
277
  设计文档写"`SyncBinaryVersion` 改 `(packageVersion, bundleHash)`"。直接改会出事:
Binary file
Binary file
@@ -96,6 +96,13 @@ final class BundledResourceCopier {
96
96
  // failures (disk full, corrupt archive) that must fail the update,
97
97
  // not skips — otherwise the update activates with missing resources.
98
98
  ArrayList<String> failedCopies = new ArrayList<String>();
99
+ // Entries whose manifest-declared CRC32 could not be satisfied by
100
+ // any installed archive: the path-matched file has different bytes
101
+ // and no entry anywhere carries the expected content. Copying would
102
+ // install a wrong resource (rebuilt binary with drifted assets), so
103
+ // these fail the whole patch — the JS strategy chain then falls
104
+ // back to the full package.
105
+ ArrayList<String> crcMismatches = new ArrayList<String>();
99
106
 
100
107
  for (String fromPath : new ArrayList<String>(remainingFiles.keySet())) {
101
108
  ArrayList<File> targets = remainingFiles.get(fromPath);
@@ -107,6 +114,7 @@ final class BundledResourceCopier {
107
114
  String actualSourcePath = fromPath;
108
115
  SafeZipFile matchedZipFile = null;
109
116
  ResolvedResourceSource resolvedResource = null;
117
+ Long wantedCrc = crcByFrom != null ? crcByFrom.get(fromPath) : null;
110
118
 
111
119
  if (entry == null) {
112
120
  String normalizedFrom = normalizeResPath(fromPath);
@@ -117,21 +125,39 @@ final class BundledResourceCopier {
117
125
  }
118
126
  }
119
127
 
128
+ // Verify a path-matched entry against the manifest-declared
129
+ // CRC32: a rebuilt binary can keep the path but drift the
130
+ // content. On mismatch, discard the path match and try to
131
+ // locate the expected content elsewhere. An entry with an
132
+ // unknown CRC (-1) cannot be verified and is accepted as-is.
133
+ if (entry != null && wantedCrc != null
134
+ && entry.getCrc() != -1L && entry.getCrc() != wantedCrc.longValue()) {
135
+ entry = null;
136
+ actualSourcePath = fromPath;
137
+ }
138
+
120
139
  // Content (CRC32) match: robust across APK/AAB packaging because
121
140
  // the checksum is over the uncompressed file content, not its
122
141
  // path. Preferred over the resource-id heuristic below.
123
- if (entry == null && crcByFrom != null) {
124
- Long wantedCrc = crcByFrom.get(fromPath);
125
- if (wantedCrc != null) {
126
- ZipSource matched = crcToEntry.get(wantedCrc);
127
- if (matched != null) {
128
- entry = matched.entry;
129
- matchedZipFile = matched.zipFile;
130
- actualSourcePath = matched.entry.getName();
131
- }
142
+ if (entry == null && wantedCrc != null) {
143
+ ZipSource matched = crcToEntry.get(wantedCrc);
144
+ if (matched != null) {
145
+ entry = matched.entry;
146
+ matchedZipFile = matched.zipFile;
147
+ actualSourcePath = matched.entry.getName();
132
148
  }
133
149
  }
134
150
 
151
+ // A declared CRC that no installed entry satisfies is a hard
152
+ // content mismatch. The resource-id heuristic below cannot be
153
+ // trusted here: the content index above already covers every
154
+ // entry, so anything the heuristic finds has the wrong bytes.
155
+ if (entry == null && wantedCrc != null) {
156
+ crcMismatches.add(fromPath);
157
+ remainingFiles.remove(fromPath);
158
+ continue;
159
+ }
160
+
135
161
  if (entry == null) {
136
162
  resolvedResource = resolveBundledResource(fromPath);
137
163
  if (resolvedResource != null) {
@@ -189,6 +215,13 @@ final class BundledResourceCopier {
189
215
  remainingFiles.remove(fromPath);
190
216
  }
191
217
 
218
+ if (!crcMismatches.isEmpty()) {
219
+ throw new IOException(
220
+ "Bundled resource content mismatch (crc32) for "
221
+ + crcMismatches.size() + " entries: " + crcMismatches
222
+ );
223
+ }
224
+
192
225
  if (!failedCopies.isEmpty()) {
193
226
  throw new IOException(
194
227
  "Failed to copy " + failedCopies.size()
@@ -77,6 +77,9 @@ class DownloadTask implements Runnable {
77
77
  private final byte[] buffer = new byte[DOWNLOAD_CHUNK_SIZE];
78
78
  private final BundledResourceCopier bundledResourceCopier;
79
79
  private String hash;
80
+ // Set once downloadFile() returns: failures after this point in a patch
81
+ // task are patch-application failures, not download failures.
82
+ private boolean downloadPhaseCompleted = false;
80
83
 
81
84
  DownloadTask(Context context, DownloadTaskParams params) {
82
85
  this.context = context.getApplicationContext();
@@ -160,6 +163,7 @@ class DownloadTask implements Runnable {
160
163
  }
161
164
  }
162
165
 
166
+ downloadPhaseCompleted = true;
163
167
  }
164
168
 
165
169
  private byte[] readBytes(InputStream input) throws IOException {
@@ -408,6 +412,12 @@ class DownloadTask implements Runnable {
408
412
  }
409
413
  }
410
414
 
415
+ private boolean isPatchTask(int taskType) {
416
+ return taskType == DownloadTaskParams.TASK_TYPE_PATCH_FULL
417
+ || taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK
418
+ || taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK;
419
+ }
420
+
411
421
  @Override
412
422
  public void run() {
413
423
  int taskType = params.type;
@@ -436,7 +446,18 @@ class DownloadTask implements Runnable {
436
446
  cleanUpAfterFailure(taskType);
437
447
 
438
448
  if (params.listener != null) {
439
- params.listener.onDownloadFailed(error);
449
+ // A patch task that failed after its artifact was fully
450
+ // downloaded (unzip / hdiff / resource copy, incl. copiesCrc
451
+ // verification) is a patch failure, not a download failure —
452
+ // the module maps the exception type to PATCH_FAILED.
453
+ Throwable classified = error;
454
+ if (downloadPhaseCompleted
455
+ && isPatchTask(taskType)
456
+ && !(error instanceof PatchFailedException)) {
457
+ classified = new PatchFailedException(
458
+ String.valueOf(error.getMessage()), error);
459
+ }
460
+ params.listener.onDownloadFailed(classified);
440
461
  }
441
462
  return;
442
463
  }
@@ -0,0 +1,16 @@
1
+ package cn.reactnative.modules.update;
2
+
3
+ import java.io.IOException;
4
+
5
+ /**
6
+ * A download task failure that happened after the artifact was fully
7
+ * downloaded — unzip, hdiff apply, or bundled-resource copy (including the
8
+ * copiesCrc content verification). UpdateModuleImpl rejects these with
9
+ * PATCH_FAILED instead of DOWNLOAD_FAILED so the JS layer and server-side
10
+ * telemetry can separate patch health from network health.
11
+ */
12
+ class PatchFailedException extends IOException {
13
+ PatchFailedException(String message, Throwable cause) {
14
+ super(message, cause);
15
+ }
16
+ }
@@ -211,12 +211,36 @@ public class UpdateContext {
211
211
  executor.execute(new DownloadTask(context, params));
212
212
  }
213
213
 
214
+ // Server-provided identifiers (hash/originHash/fileName) become child
215
+ // names under rootDir; anything that could resolve outside of it (path
216
+ // separators, "..", ".") must be rejected before touching the filesystem.
217
+ static boolean isSafePathComponent(String name) {
218
+ return name != null
219
+ && !name.isEmpty()
220
+ && !name.equals(".")
221
+ && !name.equals("..")
222
+ && !name.contains("/")
223
+ && !name.contains("\\")
224
+ && name.indexOf('\0') < 0;
225
+ }
226
+
227
+ private static boolean rejectUnsafeComponent(String name, DownloadFileListener listener) {
228
+ if (isSafePathComponent(name)) {
229
+ return false;
230
+ }
231
+ listener.onDownloadFailed(new IllegalArgumentException("Invalid path component: " + name));
232
+ return true;
233
+ }
234
+
214
235
  public interface DownloadFileListener {
215
236
  void onDownloadCompleted(DownloadTaskParams params);
216
237
  void onDownloadFailed(Throwable error);
217
238
  }
218
239
 
219
240
  public void downloadFullUpdate(String url, String hash, DownloadFileListener listener) {
241
+ if (rejectUnsafeComponent(hash, listener)) {
242
+ return;
243
+ }
220
244
  DownloadTaskParams params = new DownloadTaskParams();
221
245
  params.type = DownloadTaskParams.TASK_TYPE_PATCH_FULL;
222
246
  params.url = url;
@@ -228,6 +252,9 @@ public class UpdateContext {
228
252
  }
229
253
 
230
254
  public void downloadFile(String url, String hash, String fileName, DownloadFileListener listener) {
255
+ if (rejectUnsafeComponent(fileName, listener)) {
256
+ return;
257
+ }
231
258
  DownloadTaskParams params = new DownloadTaskParams();
232
259
  params.type = DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD;
233
260
  params.url = url;
@@ -246,6 +273,9 @@ public class UpdateContext {
246
273
  }
247
274
 
248
275
  public void downloadPatchFromApk(String url, String hash, DownloadFileListener listener) {
276
+ if (rejectUnsafeComponent(hash, listener)) {
277
+ return;
278
+ }
249
279
  DownloadTaskParams params = new DownloadTaskParams();
250
280
  params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK;
251
281
  params.url = url;
@@ -257,6 +287,9 @@ public class UpdateContext {
257
287
  }
258
288
 
259
289
  public void downloadPatchFromPpk(String url, String hash, String originHash, DownloadFileListener listener) {
290
+ if (rejectUnsafeComponent(hash, listener) || rejectUnsafeComponent(originHash, listener)) {
291
+ return;
292
+ }
260
293
  DownloadTaskParams params = new DownloadTaskParams();
261
294
  params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK;
262
295
  params.url = url;
@@ -312,6 +345,9 @@ public class UpdateContext {
312
345
  }
313
346
 
314
347
  public void switchVersion(String hash) {
348
+ if (!isSafePathComponent(hash)) {
349
+ throw new IllegalArgumentException("Invalid hash: " + hash);
350
+ }
315
351
  if (!new File(rootDir, hash+"/index.bundlejs").exists()) {
316
352
  throw new IllegalStateException("Bundle version " + hash + " not found.");
317
353
  }
@@ -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) {
@@ -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 {
@@ -814,6 +814,102 @@ void TestSha256File() {
814
814
  "Sha256File over a missing file returns empty");
815
815
  }
816
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
+
817
913
  } // namespace
818
914
 
819
915
  int main(int argc, char** argv) {
@@ -848,6 +944,9 @@ int main(int argc, char** argv) {
848
944
  {"Sha256KnownVectors", TestSha256KnownVectors},
849
945
  {"Sha256StreamingMatchesOneShot", TestSha256StreamingMatchesOneShot},
850
946
  {"Sha256File", TestSha256File},
947
+ {"Crc32KnownVectors", TestCrc32KnownVectors},
948
+ {"Crc32File", TestCrc32File},
949
+ {"ApplyPatchCopiesVerifyExpectedCrc", TestApplyPatchCopiesVerifyExpectedCrc},
851
950
  };
852
951
 
853
952
  int failures = 0;
@@ -936,6 +936,52 @@ static napi_value Sha256Hex(napi_env env, napi_callback_info info) {
936
936
  return result;
937
937
  }
938
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
+
939
985
  // 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道)
940
986
  static napi_value GetSupportedDiffVersion(napi_env env, napi_callback_info) {
941
987
  napi_value result = nullptr;
@@ -954,6 +1000,7 @@ napi_value Init(napi_env env, napi_value exports) {
954
1000
  !ExportFunction(env, exports, "applyPatchFromFileSource", ApplyPatchFromFileSource) ||
955
1001
  !ExportFunction(env, exports, "cleanupOldEntries", CleanupOldEntries) ||
956
1002
  !ExportFunction(env, exports, "sha256Hex", Sha256Hex) ||
1003
+ !ExportFunction(env, exports, "crc32", Crc32) ||
957
1004
  !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion)) {
958
1005
  return nullptr;
959
1006
  }