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.
- package/BUNDLEHASH_MIGRATION.md +22 -2
- 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/BundledResourceCopier.java +42 -9
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +22 -1
- package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +36 -0
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +12 -3
- package/cpp/patch_core/digest.cpp +53 -0
- package/cpp/patch_core/digest.h +18 -0
- package/cpp/patch_core/patch_core.cpp +17 -1
- package/cpp/patch_core/patch_core.h +8 -0
- package/cpp/patch_core/tests/patch_core_test.cpp +99 -0
- package/harmony/pushy/src/main/cpp/pushy.cpp +47 -0
- package/harmony/pushy/src/main/ets/DownloadTask.ts +85 -0
- package/harmony/pushy/src/main/ets/NativePatchCore.ts +2 -0
- package/harmony/pushy/src/main/ets/UpdateContext.ts +25 -4
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +61 -7
- package/package.json +1 -1
- package/src/client.ts +98 -4
- package/src/endpoint.ts +98 -51
- package/src/error.ts +4 -0
- package/src/isInRollout.ts +2 -1
- package/src/locales/en.ts +4 -0
- package/src/locales/zh.ts +4 -0
- package/src/provider.tsx +36 -7
- package/src/telemetry.ts +8 -0
- package/src/type.ts +5 -0
- package/src/utils.ts +13 -0
package/BUNDLEHASH_MIGRATION.md
CHANGED
|
@@ -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
|
-
> -
|
|
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
|
-
-
|
|
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
|
|
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 &&
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
package/cpp/patch_core/digest.h
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|