Skip to content

Commit 4ca8972

Browse files
sunnylqmclaude
andauthored
fix(install): make bundleSha256 mandatory in schema-1 install records (#655)
* fix(install): make bundleSha256 mandatory in schema-1 install records A hashing failure at install time used to produce a schema-1 record without bundleSha256, and every verifier treated a digest-less record as "nothing to verify" — so switchVersion activated that version unverified. Now, on iOS, Android and Harmony alike: - the writer fails the install (PATCH_FAILED, staging dropped, no record) when the bundle digest is empty or cannot be computed; - activation rejects a non-empty record whose bundleSha256 is missing, empty or not a string, via the existing "completion record mismatch" SWITCH_VERSION_FAILED path; - the legacy EMPTY marker (SDK < 10.53) stays trusted. install_record.h documents the digest as mandatory for schema 1. Tests: Android InstallRecordTest, Harmony hypium InstallRecord.test.ets, a bun test that evaluates the real Harmony InstallRecord.ts in a vm, and the iOS purge-restore harness now extracts PushyVerifyInstallForActivation with a negative-control mutant that re-trusts digest-less records. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(switch): verify recorded current/last versions instead of grandfathering them switchVersion skipped install-record verification whenever the target was current_version or last_version, so a digest-less schema-1 record could still be activated through that path. The grandfathering now applies only to directories with NO record file (activated before markers existed); a present record is verified like any other, and the empty legacy marker stays trusted. iOS applies the same predicate in the state-lock recheck. The iOS purge-restore harness now extracts the production switchVersion:error: and covers it (switch_version_records), with a grandfather-recorded negative-control mutant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7e4e095 commit 4ca8972

14 files changed

Lines changed: 592 additions & 65 deletions

File tree

‎android/src/main/java/cn/reactnative/modules/update/InstallRecord.java‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,19 @@ static File stagingDirectoryFor(File versionDir) {
2727
return new File(versionDir.getPath() + STAGING_SUFFIX);
2828
}
2929

30+
/**
31+
* bundleSha256 is mandatory for schema 1: a version whose bundle could
32+
* not be hashed must fail its install, never be recorded unverifiable.
33+
*/
3034
static String build(String versionHash, String bundleSha256, String artifactSha256)
31-
throws JSONException {
35+
throws IOException, JSONException {
36+
if (bundleSha256 == null || bundleSha256.isEmpty()) {
37+
throw new IOException("Bundle version " + versionHash + " has no bundle digest.");
38+
}
3239
JSONObject record = new JSONObject();
3340
record.put("schema", SCHEMA);
3441
record.put("versionHash", versionHash);
35-
if (bundleSha256 != null && !bundleSha256.isEmpty()) {
36-
record.put("bundleSha256", bundleSha256);
37-
}
42+
record.put("bundleSha256", bundleSha256);
3843
if (artifactSha256 != null && !artifactSha256.isEmpty()) {
3944
record.put("artifactSha256", artifactSha256);
4045
}
@@ -123,7 +128,8 @@ static boolean isComplete(File versionDir, String versionHash) {
123128
}
124129

125130
/**
126-
* Activation check: re-hashes the bundle when the record carries a digest.
131+
* Activation check: re-hashes the bundle against the record's digest,
132+
* which schema 1 makes mandatory (only the legacy empty marker has none).
127133
* Throws with the reason when the directory must not be activated.
128134
*/
129135
static void verifyForActivation(File versionDir, String versionHash, File bundleFile)
@@ -139,9 +145,10 @@ static void verifyForActivation(File versionDir, String versionHash, File bundle
139145
|| !versionHash.equals(record.optString("versionHash", ""))) {
140146
throw new IOException("Bundle version " + versionHash + " completion record mismatch.");
141147
}
142-
String expected = record.optString("bundleSha256", "");
148+
Object digest = record.opt("bundleSha256");
149+
String expected = digest instanceof String ? (String) digest : "";
143150
if (expected.isEmpty()) {
144-
return;
151+
throw new IOException("Bundle version " + versionHash + " completion record mismatch.");
145152
}
146153
String actual = UpdateFileUtils.sha256Hex(bundleFile);
147154
if (!expected.equalsIgnoreCase(actual)) {

‎android/src/main/java/cn/reactnative/modules/update/UpdateContext.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,9 +533,10 @@ private void verifySwitchTarget(String hash) {
533533
StateCoreResult currentState = getStateSnapshot();
534534
boolean isLegacyActivatedVersion = hash.equals(currentState.currentVersion)
535535
|| hash.equals(currentState.lastVersion);
536-
if (isLegacyActivatedVersion) {
536+
if (isLegacyActivatedVersion && !new File(versionDir, InstallRecord.FILE_NAME).exists()) {
537537
// Versions activated before completion markers were introduced are
538-
// explicitly grandfathered through current/last state.
538+
// explicitly grandfathered through current/last state. A directory
539+
// that does carry a record is verified like any other.
539540
return;
540541
}
541542
if (!InstallRecord.isComplete(versionDir, hash)) {

‎android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java‎

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import java.nio.charset.StandardCharsets;
1414
import java.security.MessageDigest;
1515
import java.util.Arrays;
16-
import org.json.JSONException;
1716
import org.json.JSONObject;
1817
import org.junit.Rule;
1918
import org.junit.Test;
@@ -41,17 +40,29 @@ private static String sha256Hex(byte[] bytes) throws Exception {
4140
}
4241

4342
@Test
44-
public void buildWritesSchemaAndOnlyPresentDigests() throws JSONException {
43+
public void buildWritesSchemaAndDigests() throws Exception {
4544
JSONObject full = new JSONObject(InstallRecord.build(HASH, "bb", "aa"));
4645
assertEquals(InstallRecord.SCHEMA, full.getInt("schema"));
4746
assertEquals(HASH, full.getString("versionHash"));
4847
assertEquals("bb", full.getString("bundleSha256"));
4948
assertEquals("aa", full.getString("artifactSha256"));
5049

51-
JSONObject bare = new JSONObject(InstallRecord.build(HASH, "", null));
52-
assertEquals(HASH, bare.getString("versionHash"));
53-
assertFalse(bare.has("bundleSha256"));
54-
assertFalse(bare.has("artifactSha256"));
50+
// artifactSha256 is optional diagnostics.
51+
JSONObject noArtifact = new JSONObject(InstallRecord.build(HASH, "bb", null));
52+
assertEquals("bb", noArtifact.getString("bundleSha256"));
53+
assertFalse(noArtifact.has("artifactSha256"));
54+
}
55+
56+
@Test
57+
public void buildRefusesARecordWithoutBundleDigest() throws Exception {
58+
for (String missing : new String[] {"", null}) {
59+
try {
60+
InstallRecord.build(HASH, missing, "aa");
61+
fail("a record without bundleSha256 must not be built");
62+
} catch (IOException expected) {
63+
assertTrue(expected.getMessage().contains("no bundle digest"));
64+
}
65+
}
5566
}
5667

5768
@Test
@@ -128,18 +139,42 @@ public void verifyForActivationChecksTheBundleDigest() throws Exception {
128139
}
129140

130141
@Test
131-
public void verifyForActivationAcceptsRecordsWithoutDigestAndLegacyMarkers() throws Exception {
142+
public void verifyForActivationTrustsLegacyEmptyMarkers() throws Exception {
132143
File dir = temp.newFolder();
133144
File bundle = new File(dir, "index.bundlejs");
134145
writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8));
135146

136-
InstallRecord.write(dir, InstallRecord.build(HASH, "", ""));
137-
InstallRecord.verifyForActivation(dir, HASH, bundle);
138-
139147
writeFile(new File(dir, InstallRecord.FILE_NAME), new byte[0]);
140148
InstallRecord.verifyForActivation(dir, HASH, bundle);
141149
}
142150

151+
@Test
152+
public void verifyForActivationRejectsSchemaOneRecordsWithoutDigest() throws Exception {
153+
File dir = temp.newFolder();
154+
File bundle = new File(dir, "index.bundlejs");
155+
writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8));
156+
File marker = new File(dir, InstallRecord.FILE_NAME);
157+
String prefix = "{\"schema\":" + InstallRecord.SCHEMA + ",\"versionHash\":\"" + HASH + "\"";
158+
String[] records = {
159+
prefix + "}",
160+
prefix + ",\"bundleSha256\":\"\"}",
161+
prefix + ",\"bundleSha256\":null}",
162+
prefix + ",\"bundleSha256\":123}",
163+
prefix + ",\"artifactSha256\":\"aa\"}",
164+
};
165+
for (String json : records) {
166+
writeFile(marker, json.getBytes(StandardCharsets.UTF_8));
167+
// Still present for launch / dedup: only activation is refused.
168+
assertTrue(InstallRecord.isComplete(dir, HASH));
169+
try {
170+
InstallRecord.verifyForActivation(dir, HASH, bundle);
171+
fail("digest-less record must not activate: " + json);
172+
} catch (IOException expected) {
173+
assertTrue(expected.getMessage().contains("record mismatch"));
174+
}
175+
}
176+
}
177+
143178
@Test
144179
public void verifyForActivationRejectsMissingOrForeignRecords() throws Exception {
145180
File dir = temp.newFolder();
@@ -152,7 +187,7 @@ public void verifyForActivationRejectsMissingOrForeignRecords() throws Exception
152187
assertTrue(expected.getMessage().contains("no valid completion record"));
153188
}
154189

155-
InstallRecord.write(dir, InstallRecord.build("anotherhash", "", ""));
190+
InstallRecord.write(dir, InstallRecord.build("anotherhash", "bb", ""));
156191
try {
157192
InstallRecord.verifyForActivation(dir, HASH, bundle);
158193
fail("record for another version must not activate");

‎cpp/patch_core/install_record.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
// - versionHash must equal the directory name; bundleSha256 is the digest of
1515
// the final index.bundlejs (bundle.harmony.js on Harmony) and is
1616
// re-verified at switchVersion time; artifactSha256 is the digest of the
17-
// downloaded archive (diagnostics / CDN corruption attribution).
17+
// downloaded archive (diagnostics / CDN corruption attribution, optional).
18+
// - bundleSha256 is MANDATORY for schema 1. A writer whose bundle digest is
19+
// empty or fails must fail the install (staging dropped, no record); a
20+
// verifier must reject a non-empty record without one (record mismatch),
21+
// never activate the version unverified.
1822
// - Legacy: an EMPTY file is a completed install written by SDK < 10.53
1923
// (no digests); it stays trusted for presence, nothing to verify.
2024
//

‎harmony/pushy/src/main/ets/InstallRecord.ts‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import fileIo from '@ohos.file.fs';
22
import { util } from '@kit.ArkTS';
33
import NativePatchCore from './NativePatchCore';
4-
import { ERROR_SWITCH_VERSION_FAILED, createPushyError } from './ErrorCodes';
4+
import {
5+
ERROR_DELTA_FAILED,
6+
ERROR_SWITCH_VERSION_FAILED,
7+
createPushyError,
8+
} from './ErrorCodes';
59

610
// ArkTS mirror of cpp/patch_core/install_record.h — keep in sync by hand
711
// (harmony/pushy/src/test/check-constant-parity.js asserts the values match).
@@ -36,13 +40,19 @@ export function buildInstallRecord(
3640
bundleSha256: string,
3741
artifactSha256: string,
3842
): string {
43+
// schema 1 的 bundleSha256 是必填:算不出摘要的版本必须安装失败,绝不能
44+
// 写成一条无法复核的记录。
45+
if (!bundleSha256) {
46+
throw createPushyError(
47+
ERROR_DELTA_FAILED,
48+
`Bundle version ${versionHash} has no bundle digest.`,
49+
);
50+
}
3951
const record: InstallRecordData = {
4052
schema: INSTALL_RECORD_SCHEMA,
4153
versionHash,
54+
bundleSha256,
4255
};
43-
if (bundleSha256) {
44-
record.bundleSha256 = bundleSha256;
45-
}
4656
if (artifactSha256) {
4757
record.artifactSha256 = artifactSha256;
4858
}
@@ -133,8 +143,9 @@ export function isInstallComplete(versionDir: string, versionHash: string): bool
133143
}
134144

135145
/**
136-
* 激活前的记录校验(不含摘要):返回需要复核的 bundle 摘要,历史空记录或
137-
* 无摘要记录返回空串;记录缺失/不符抛 SWITCH_VERSION_FAILED。纯函数。
146+
* 激活前的记录校验(不含摘要):返回需要复核的 bundle 摘要,仅历史空记录
147+
* 返回空串;记录缺失/不符或缺摘要(schema 1 必填)抛 SWITCH_VERSION_FAILED。
148+
* 纯函数。
138149
*/
139150
export function expectedBundleSha256ForActivation(
140151
record: InstallRecordData | null,
@@ -158,11 +169,18 @@ export function expectedBundleSha256ForActivation(
158169
`Bundle version ${versionHash} completion record mismatch.`,
159170
);
160171
}
161-
return record.bundleSha256 ?? '';
172+
const expected = record.bundleSha256;
173+
if (typeof expected !== 'string' || expected.length === 0) {
174+
throw createPushyError(
175+
ERROR_SWITCH_VERSION_FAILED,
176+
`Bundle version ${versionHash} completion record mismatch.`,
177+
);
178+
}
179+
return expected;
162180
}
163181

164182
/**
165-
* 激活前校验:记录带摘要时在 native 工作线程重新计算 bundle 摘要(整包
183+
* 激活前校验:非历史空记录时在 native 工作线程重新计算 bundle 摘要(整包
166184
* 几十 MB,不能在 UI 线程同步算);不通过则抛错说明原因。
167185
*/
168186
export async function verifyInstallForActivation(

‎harmony/pushy/src/main/ets/UpdateContext.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import preferences from '@ohos.data.preferences';
22
import fileIo from '@ohos.file.fs';
33
import {
4+
INSTALL_RECORD_FILE_NAME,
45
isInstallComplete,
56
readInstallRecord,
67
verifyInstallForActivation,
@@ -842,9 +843,10 @@ export class UpdateContext {
842843

843844
/**
844845
* 激活前置校验(同步,不写任何东西):bundle 在盘上,且要么是本 SDK 记录为
845-
* 完整安装的版本(bundle + 记录),要么是标记机制之前就激活过的历史版本
846-
* (current/last 里有它)。其他无记录的目录可能是崩溃留下的半成品。
847-
* 返回是否还需复核安装记录里的 bundle 摘要(历史版本无记录可核)。
846+
* 完整安装的版本(bundle + 记录),要么是标记机制之前就激活过、目录里没有
847+
* 记录文件的历史版本(current/last 里有它)。其他无记录的目录可能是崩溃
848+
* 留下的半成品;有记录文件的目录即便是 current/last 也照常复核。
849+
* 返回是否还需复核安装记录(无记录的历史版本无可核)。
848850
*/
849851
private assertActivatable(safeHash: string): boolean {
850852
if (!fileIo.accessSync(this.getBundlePath(safeHash))) {
@@ -854,8 +856,9 @@ export class UpdateContext {
854856
);
855857
}
856858
const legacyActivated =
857-
this.readString('currentVersion') === safeHash ||
858-
this.readString('lastVersion') === safeHash;
859+
!fileIo.accessSync(`${this.rootDir}/${safeHash}/${INSTALL_RECORD_FILE_NAME}`) &&
860+
(this.readString('currentVersion') === safeHash ||
861+
this.readString('lastVersion') === safeHash);
859862
if (!this.hasDownloadedVersion(safeHash) && !legacyActivated) {
860863
throw createPushyError(
861864
ERROR_SWITCH_VERSION_FAILED,

‎harmony/pushy/src/test/InstallRecord.test.ets‎

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect } from '@ohos/hypium';
22
import {
33
INSTALL_RECORD_FILE_NAME,
44
INSTALL_RECORD_SCHEMA,
5+
InstallRecordData,
56
STAGING_SUFFIX,
67
buildInstallRecord,
78
expectedBundleSha256ForActivation,
@@ -10,7 +11,11 @@ import {
1011
parseInstallRecord,
1112
stagingDirectoryFor,
1213
} from '../main/ets/InstallRecord';
13-
import { ERROR_SWITCH_VERSION_FAILED, PushyError } from '../main/ets/ErrorCodes';
14+
import {
15+
ERROR_DELTA_FAILED,
16+
ERROR_SWITCH_VERSION_FAILED,
17+
PushyError,
18+
} from '../main/ets/ErrorCodes';
1419

1520
// 两阶段安装的完成记录(cpp/patch_core/install_record.h 的 ArkTS 镜像):
1621
// 纯逻辑部分,不碰磁盘。
@@ -27,13 +32,24 @@ export default function installRecordTest() {
2732
expect(record!.artifactSha256).assertEqual('ARTIFACT');
2833
});
2934

30-
it('omits empty digests', 0, () => {
31-
const record = parseInstallRecord(buildInstallRecord('abc', '', ''));
35+
it('omits an empty artifact digest', 0, () => {
36+
const record = parseInstallRecord(buildInstallRecord('abc', 'BUNDLE', ''));
3237
expect(record !== null).assertTrue();
33-
expect(record!.bundleSha256 === undefined).assertTrue();
38+
expect(record!.bundleSha256).assertEqual('BUNDLE');
3439
expect(record!.artifactSha256 === undefined).assertTrue();
3540
});
3641

42+
it('refuses to build a record without a bundle digest', 0, () => {
43+
let caught: PushyError | null = null;
44+
try {
45+
buildInstallRecord('abc', '', 'ARTIFACT');
46+
} catch (e) {
47+
caught = e as PushyError;
48+
}
49+
expect(caught !== null).assertTrue();
50+
expect(caught!.code).assertEqual(ERROR_DELTA_FAILED);
51+
});
52+
3753
it('derives the staging directory from the version directory', 0, () => {
3854
expect(stagingDirectoryFor('/data/_update/abc')).assertEqual(
3955
`/data/_update/abc${STAGING_SUFFIX}`,
@@ -73,7 +89,7 @@ export default function installRecordTest() {
7389
});
7490

7591
it('requires the schema and version to match', 0, () => {
76-
const record = parseInstallRecord(buildInstallRecord('abc', '', ''));
92+
const record = parseInstallRecord(buildInstallRecord('abc', 'DIGEST', ''));
7793
expect(isRecordForVersion(record, 'abc')).assertTrue();
7894
expect(isRecordForVersion(record, 'def')).assertFalse();
7995
expect(
@@ -95,14 +111,27 @@ export default function installRecordTest() {
95111
expect(caught!.message.startsWith(`[${ERROR_SWITCH_VERSION_FAILED}] `)).assertTrue();
96112
});
97113

98-
it('has nothing to verify for legacy or digest-less records', 0, () => {
114+
it('has nothing to verify for a legacy empty record', 0, () => {
99115
expect(expectedBundleSha256ForActivation({}, 'abc')).assertEqual('');
100-
expect(
101-
expectedBundleSha256ForActivation(
102-
parseInstallRecord(buildInstallRecord('abc', '', '')),
103-
'abc',
104-
),
105-
).assertEqual('');
116+
});
117+
118+
it('rejects a schema 1 record without a bundle digest', 0, () => {
119+
const records: InstallRecordData[] = [
120+
{ schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc' },
121+
{ schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc', bundleSha256: '' },
122+
{ schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc', artifactSha256: 'A' },
123+
];
124+
for (const record of records) {
125+
let caught: PushyError | null = null;
126+
try {
127+
expectedBundleSha256ForActivation(record, 'abc');
128+
} catch (e) {
129+
caught = e as PushyError;
130+
}
131+
expect(caught !== null).assertTrue();
132+
expect(caught!.code).assertEqual(ERROR_SWITCH_VERSION_FAILED);
133+
expect(caught!.message.includes('record mismatch')).assertTrue();
134+
}
106135
});
107136

108137
it('returns the recorded digest for a matching record', 0, () => {

0 commit comments

Comments
 (0)