diff --git a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java index b50bff32..a1cdae09 100644 --- a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +++ b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java @@ -27,14 +27,19 @@ static File stagingDirectoryFor(File versionDir) { return new File(versionDir.getPath() + STAGING_SUFFIX); } + /** + * bundleSha256 is mandatory for schema 1: a version whose bundle could + * not be hashed must fail its install, never be recorded unverifiable. + */ static String build(String versionHash, String bundleSha256, String artifactSha256) - throws JSONException { + throws IOException, JSONException { + if (bundleSha256 == null || bundleSha256.isEmpty()) { + throw new IOException("Bundle version " + versionHash + " has no bundle digest."); + } JSONObject record = new JSONObject(); record.put("schema", SCHEMA); record.put("versionHash", versionHash); - if (bundleSha256 != null && !bundleSha256.isEmpty()) { - record.put("bundleSha256", bundleSha256); - } + record.put("bundleSha256", bundleSha256); if (artifactSha256 != null && !artifactSha256.isEmpty()) { record.put("artifactSha256", artifactSha256); } @@ -123,7 +128,8 @@ static boolean isComplete(File versionDir, String versionHash) { } /** - * Activation check: re-hashes the bundle when the record carries a digest. + * Activation check: re-hashes the bundle against the record's digest, + * which schema 1 makes mandatory (only the legacy empty marker has none). * Throws with the reason when the directory must not be activated. */ static void verifyForActivation(File versionDir, String versionHash, File bundleFile) @@ -139,9 +145,10 @@ static void verifyForActivation(File versionDir, String versionHash, File bundle || !versionHash.equals(record.optString("versionHash", ""))) { throw new IOException("Bundle version " + versionHash + " completion record mismatch."); } - String expected = record.optString("bundleSha256", ""); + Object digest = record.opt("bundleSha256"); + String expected = digest instanceof String ? (String) digest : ""; if (expected.isEmpty()) { - return; + throw new IOException("Bundle version " + versionHash + " completion record mismatch."); } String actual = UpdateFileUtils.sha256Hex(bundleFile); if (!expected.equalsIgnoreCase(actual)) { diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java index 96979d26..50bbd2e2 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -533,9 +533,10 @@ private void verifySwitchTarget(String hash) { StateCoreResult currentState = getStateSnapshot(); boolean isLegacyActivatedVersion = hash.equals(currentState.currentVersion) || hash.equals(currentState.lastVersion); - if (isLegacyActivatedVersion) { + if (isLegacyActivatedVersion && !new File(versionDir, InstallRecord.FILE_NAME).exists()) { // Versions activated before completion markers were introduced are - // explicitly grandfathered through current/last state. + // explicitly grandfathered through current/last state. A directory + // that does carry a record is verified like any other. return; } if (!InstallRecord.isComplete(versionDir, hash)) { diff --git a/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java b/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java index bdef9e68..6b9d1707 100644 --- a/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java @@ -13,7 +13,6 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Arrays; -import org.json.JSONException; import org.json.JSONObject; import org.junit.Rule; import org.junit.Test; @@ -41,17 +40,29 @@ private static String sha256Hex(byte[] bytes) throws Exception { } @Test - public void buildWritesSchemaAndOnlyPresentDigests() throws JSONException { + public void buildWritesSchemaAndDigests() throws Exception { JSONObject full = new JSONObject(InstallRecord.build(HASH, "bb", "aa")); assertEquals(InstallRecord.SCHEMA, full.getInt("schema")); assertEquals(HASH, full.getString("versionHash")); assertEquals("bb", full.getString("bundleSha256")); assertEquals("aa", full.getString("artifactSha256")); - JSONObject bare = new JSONObject(InstallRecord.build(HASH, "", null)); - assertEquals(HASH, bare.getString("versionHash")); - assertFalse(bare.has("bundleSha256")); - assertFalse(bare.has("artifactSha256")); + // artifactSha256 is optional diagnostics. + JSONObject noArtifact = new JSONObject(InstallRecord.build(HASH, "bb", null)); + assertEquals("bb", noArtifact.getString("bundleSha256")); + assertFalse(noArtifact.has("artifactSha256")); + } + + @Test + public void buildRefusesARecordWithoutBundleDigest() throws Exception { + for (String missing : new String[] {"", null}) { + try { + InstallRecord.build(HASH, missing, "aa"); + fail("a record without bundleSha256 must not be built"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("no bundle digest")); + } + } } @Test @@ -128,18 +139,42 @@ public void verifyForActivationChecksTheBundleDigest() throws Exception { } @Test - public void verifyForActivationAcceptsRecordsWithoutDigestAndLegacyMarkers() throws Exception { + public void verifyForActivationTrustsLegacyEmptyMarkers() throws Exception { File dir = temp.newFolder(); File bundle = new File(dir, "index.bundlejs"); writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8)); - InstallRecord.write(dir, InstallRecord.build(HASH, "", "")); - InstallRecord.verifyForActivation(dir, HASH, bundle); - writeFile(new File(dir, InstallRecord.FILE_NAME), new byte[0]); InstallRecord.verifyForActivation(dir, HASH, bundle); } + @Test + public void verifyForActivationRejectsSchemaOneRecordsWithoutDigest() throws Exception { + File dir = temp.newFolder(); + File bundle = new File(dir, "index.bundlejs"); + writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8)); + File marker = new File(dir, InstallRecord.FILE_NAME); + String prefix = "{\"schema\":" + InstallRecord.SCHEMA + ",\"versionHash\":\"" + HASH + "\""; + String[] records = { + prefix + "}", + prefix + ",\"bundleSha256\":\"\"}", + prefix + ",\"bundleSha256\":null}", + prefix + ",\"bundleSha256\":123}", + prefix + ",\"artifactSha256\":\"aa\"}", + }; + for (String json : records) { + writeFile(marker, json.getBytes(StandardCharsets.UTF_8)); + // Still present for launch / dedup: only activation is refused. + assertTrue(InstallRecord.isComplete(dir, HASH)); + try { + InstallRecord.verifyForActivation(dir, HASH, bundle); + fail("digest-less record must not activate: " + json); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("record mismatch")); + } + } + } + @Test public void verifyForActivationRejectsMissingOrForeignRecords() throws Exception { File dir = temp.newFolder(); @@ -152,7 +187,7 @@ public void verifyForActivationRejectsMissingOrForeignRecords() throws Exception assertTrue(expected.getMessage().contains("no valid completion record")); } - InstallRecord.write(dir, InstallRecord.build("anotherhash", "", "")); + InstallRecord.write(dir, InstallRecord.build("anotherhash", "bb", "")); try { InstallRecord.verifyForActivation(dir, HASH, bundle); fail("record for another version must not activate"); diff --git a/cpp/patch_core/install_record.h b/cpp/patch_core/install_record.h index 9830e95c..0554dddc 100644 --- a/cpp/patch_core/install_record.h +++ b/cpp/patch_core/install_record.h @@ -14,7 +14,11 @@ // - versionHash must equal the directory name; bundleSha256 is the digest of // the final index.bundlejs (bundle.harmony.js on Harmony) and is // re-verified at switchVersion time; artifactSha256 is the digest of the -// downloaded archive (diagnostics / CDN corruption attribution). +// downloaded archive (diagnostics / CDN corruption attribution, optional). +// - bundleSha256 is MANDATORY for schema 1. A writer whose bundle digest is +// empty or fails must fail the install (staging dropped, no record); a +// verifier must reject a non-empty record without one (record mismatch), +// never activate the version unverified. // - Legacy: an EMPTY file is a completed install written by SDK < 10.53 // (no digests); it stays trusted for presence, nothing to verify. // diff --git a/harmony/pushy/src/main/ets/InstallRecord.ts b/harmony/pushy/src/main/ets/InstallRecord.ts index 8487ec03..aeade0e6 100644 --- a/harmony/pushy/src/main/ets/InstallRecord.ts +++ b/harmony/pushy/src/main/ets/InstallRecord.ts @@ -1,7 +1,11 @@ import fileIo from '@ohos.file.fs'; import { util } from '@kit.ArkTS'; import NativePatchCore from './NativePatchCore'; -import { ERROR_SWITCH_VERSION_FAILED, createPushyError } from './ErrorCodes'; +import { + ERROR_DELTA_FAILED, + ERROR_SWITCH_VERSION_FAILED, + createPushyError, +} from './ErrorCodes'; // ArkTS mirror of cpp/patch_core/install_record.h — keep in sync by hand // (harmony/pushy/src/test/check-constant-parity.js asserts the values match). @@ -36,13 +40,19 @@ export function buildInstallRecord( bundleSha256: string, artifactSha256: string, ): string { + // schema 1 的 bundleSha256 是必填:算不出摘要的版本必须安装失败,绝不能 + // 写成一条无法复核的记录。 + if (!bundleSha256) { + throw createPushyError( + ERROR_DELTA_FAILED, + `Bundle version ${versionHash} has no bundle digest.`, + ); + } const record: InstallRecordData = { schema: INSTALL_RECORD_SCHEMA, versionHash, + bundleSha256, }; - if (bundleSha256) { - record.bundleSha256 = bundleSha256; - } if (artifactSha256) { record.artifactSha256 = artifactSha256; } @@ -133,8 +143,9 @@ export function isInstallComplete(versionDir: string, versionHash: string): bool } /** - * 激活前的记录校验(不含摘要):返回需要复核的 bundle 摘要,历史空记录或 - * 无摘要记录返回空串;记录缺失/不符抛 SWITCH_VERSION_FAILED。纯函数。 + * 激活前的记录校验(不含摘要):返回需要复核的 bundle 摘要,仅历史空记录 + * 返回空串;记录缺失/不符或缺摘要(schema 1 必填)抛 SWITCH_VERSION_FAILED。 + * 纯函数。 */ export function expectedBundleSha256ForActivation( record: InstallRecordData | null, @@ -158,11 +169,18 @@ export function expectedBundleSha256ForActivation( `Bundle version ${versionHash} completion record mismatch.`, ); } - return record.bundleSha256 ?? ''; + const expected = record.bundleSha256; + if (typeof expected !== 'string' || expected.length === 0) { + throw createPushyError( + ERROR_SWITCH_VERSION_FAILED, + `Bundle version ${versionHash} completion record mismatch.`, + ); + } + return expected; } /** - * 激活前校验:记录带摘要时在 native 工作线程重新计算 bundle 摘要(整包 + * 激活前校验:非历史空记录时在 native 工作线程重新计算 bundle 摘要(整包 * 几十 MB,不能在 UI 线程同步算);不通过则抛错说明原因。 */ export async function verifyInstallForActivation( diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index c0e6027e..f8199dbc 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -1,6 +1,7 @@ import preferences from '@ohos.data.preferences'; import fileIo from '@ohos.file.fs'; import { + INSTALL_RECORD_FILE_NAME, isInstallComplete, readInstallRecord, verifyInstallForActivation, @@ -842,9 +843,10 @@ export class UpdateContext { /** * 激活前置校验(同步,不写任何东西):bundle 在盘上,且要么是本 SDK 记录为 - * 完整安装的版本(bundle + 记录),要么是标记机制之前就激活过的历史版本 - * (current/last 里有它)。其他无记录的目录可能是崩溃留下的半成品。 - * 返回是否还需复核安装记录里的 bundle 摘要(历史版本无记录可核)。 + * 完整安装的版本(bundle + 记录),要么是标记机制之前就激活过、目录里没有 + * 记录文件的历史版本(current/last 里有它)。其他无记录的目录可能是崩溃 + * 留下的半成品;有记录文件的目录即便是 current/last 也照常复核。 + * 返回是否还需复核安装记录(无记录的历史版本无可核)。 */ private assertActivatable(safeHash: string): boolean { if (!fileIo.accessSync(this.getBundlePath(safeHash))) { @@ -854,8 +856,9 @@ export class UpdateContext { ); } const legacyActivated = - this.readString('currentVersion') === safeHash || - this.readString('lastVersion') === safeHash; + !fileIo.accessSync(`${this.rootDir}/${safeHash}/${INSTALL_RECORD_FILE_NAME}`) && + (this.readString('currentVersion') === safeHash || + this.readString('lastVersion') === safeHash); if (!this.hasDownloadedVersion(safeHash) && !legacyActivated) { throw createPushyError( ERROR_SWITCH_VERSION_FAILED, diff --git a/harmony/pushy/src/test/InstallRecord.test.ets b/harmony/pushy/src/test/InstallRecord.test.ets index 148ca46a..a025942d 100644 --- a/harmony/pushy/src/test/InstallRecord.test.ets +++ b/harmony/pushy/src/test/InstallRecord.test.ets @@ -2,6 +2,7 @@ import { describe, it, expect } from '@ohos/hypium'; import { INSTALL_RECORD_FILE_NAME, INSTALL_RECORD_SCHEMA, + InstallRecordData, STAGING_SUFFIX, buildInstallRecord, expectedBundleSha256ForActivation, @@ -10,7 +11,11 @@ import { parseInstallRecord, stagingDirectoryFor, } from '../main/ets/InstallRecord'; -import { ERROR_SWITCH_VERSION_FAILED, PushyError } from '../main/ets/ErrorCodes'; +import { + ERROR_DELTA_FAILED, + ERROR_SWITCH_VERSION_FAILED, + PushyError, +} from '../main/ets/ErrorCodes'; // 两阶段安装的完成记录(cpp/patch_core/install_record.h 的 ArkTS 镜像): // 纯逻辑部分,不碰磁盘。 @@ -27,13 +32,24 @@ export default function installRecordTest() { expect(record!.artifactSha256).assertEqual('ARTIFACT'); }); - it('omits empty digests', 0, () => { - const record = parseInstallRecord(buildInstallRecord('abc', '', '')); + it('omits an empty artifact digest', 0, () => { + const record = parseInstallRecord(buildInstallRecord('abc', 'BUNDLE', '')); expect(record !== null).assertTrue(); - expect(record!.bundleSha256 === undefined).assertTrue(); + expect(record!.bundleSha256).assertEqual('BUNDLE'); expect(record!.artifactSha256 === undefined).assertTrue(); }); + it('refuses to build a record without a bundle digest', 0, () => { + let caught: PushyError | null = null; + try { + buildInstallRecord('abc', '', 'ARTIFACT'); + } catch (e) { + caught = e as PushyError; + } + expect(caught !== null).assertTrue(); + expect(caught!.code).assertEqual(ERROR_DELTA_FAILED); + }); + it('derives the staging directory from the version directory', 0, () => { expect(stagingDirectoryFor('/data/_update/abc')).assertEqual( `/data/_update/abc${STAGING_SUFFIX}`, @@ -73,7 +89,7 @@ export default function installRecordTest() { }); it('requires the schema and version to match', 0, () => { - const record = parseInstallRecord(buildInstallRecord('abc', '', '')); + const record = parseInstallRecord(buildInstallRecord('abc', 'DIGEST', '')); expect(isRecordForVersion(record, 'abc')).assertTrue(); expect(isRecordForVersion(record, 'def')).assertFalse(); expect( @@ -95,14 +111,27 @@ export default function installRecordTest() { expect(caught!.message.startsWith(`[${ERROR_SWITCH_VERSION_FAILED}] `)).assertTrue(); }); - it('has nothing to verify for legacy or digest-less records', 0, () => { + it('has nothing to verify for a legacy empty record', 0, () => { expect(expectedBundleSha256ForActivation({}, 'abc')).assertEqual(''); - expect( - expectedBundleSha256ForActivation( - parseInstallRecord(buildInstallRecord('abc', '', '')), - 'abc', - ), - ).assertEqual(''); + }); + + it('rejects a schema 1 record without a bundle digest', 0, () => { + const records: InstallRecordData[] = [ + { schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc' }, + { schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc', bundleSha256: '' }, + { schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc', artifactSha256: 'A' }, + ]; + for (const record of records) { + let caught: PushyError | null = null; + try { + expectedBundleSha256ForActivation(record, 'abc'); + } catch (e) { + caught = e as PushyError; + } + expect(caught !== null).assertTrue(); + expect(caught!.code).assertEqual(ERROR_SWITCH_VERSION_FAILED); + expect(caught!.message.includes('record mismatch')).assertTrue(); + } }); it('returns the recorded digest for a matching record', 0, () => { diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index cbcef107..1e41ab0c 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -174,6 +174,14 @@ static BOOL PushyInstallRecordMatches(NSDictionary *record, NSString *hash) { && [record[@"versionHash"] isEqualToString:hash]; } +// Whether the directory carries a completion record file at all (the legacy +// empty marker included). Only directories WITHOUT one are grandfathered by +// current/last state in switchVersion; a present record is always verified. +static BOOL PushyHasInstallRecordFile(NSString *versionDir) { + return [[NSFileManager defaultManager] fileExistsAtPath: + [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]]; +} + // Presence check (launch / dedup paths, no digest work): bundle present and // the record exists and, unless legacy-empty, names this version. PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) { @@ -218,7 +226,8 @@ static PushyFileStamp PushyStampFile(NSString *path) { return stamp; } -// Activation check: re-hashes the bundle when the record carries a digest. +// Activation check: re-hashes the bundle against the record's digest, which +// schema 1 makes mandatory (only the legacy empty marker has none). // Returns nil when the directory may be activated, the reason otherwise. // Slow (a full read of the bundle): never call it under the state lock. On // success *recordOut and *stampOut describe exactly what was verified, so the @@ -242,7 +251,7 @@ static PushyFileStamp PushyStampFile(NSString *path) { } NSString *expected = record[@"bundleSha256"]; if (![expected isKindOfClass:[NSString class]] || expected.length == 0) { - return nil; + return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; } std::string actual = pushy::digest::Sha256File(PushyToStdString(bundlePath)); if (PushyStampFile(bundlePath) != before) { @@ -1569,17 +1578,20 @@ - (void)performFetch:(PushyType)type options:(NSDictionary *)options callback:(v // the staging directory, which is then renamed over the // version directory in one atomic step. NSString *bundlePath = [staging stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + // The digest is mandatory: a version whose bundle could not + // be hashed is never recorded as installed. + std::string bundleSha256; if (![fileManager fileExistsAtPath:bundlePath]) { finalError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"bundle missing after install"); + } else if ((bundleSha256 = pushy::digest::Sha256File(PushyToStdString(bundlePath))).empty()) { + finalError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + @"bundle digest failed after install"); } else { - std::string bundleSha256 = pushy::digest::Sha256File(PushyToStdString(bundlePath)); NSMutableDictionary *record = [NSMutableDictionary dictionary]; record[@"schema"] = @(pushy::install_record::kSchema); record[@"versionHash"] = hash; - if (!bundleSha256.empty()) { - record[@"bundleSha256"] = [NSString stringWithUTF8String:bundleSha256.c_str()]; - } + record[@"bundleSha256"] = [NSString stringWithUTF8String:bundleSha256.c_str()]; if (artifactSha256.length > 0) { record[@"artifactSha256"] = artifactSha256; } @@ -1821,13 +1833,17 @@ - (BOOL)switchVersion:(NSString *)hash error:(NSError **)error } // Same rule as Android: only a version this SDK recorded as completely - // installed (bundle + marker) may be activated. Versions activated before - // markers existed are grandfathered through current/last state; any other - // markerless directory may be a crash-left partial install or a directory - // something else put there. + // installed (bundle + marker) may be activated. Markerless versions + // activated before markers existed are grandfathered through current/last + // state; any other markerless directory may be a crash-left partial + // install or a directory something else put there. A directory that does + // carry a record is verified even when it is current/last. NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; std::string hashStd = PushyToStdString(hash); BOOL (^isLegacyActivated)(void) = ^BOOL { + if (PushyHasInstallRecordFile(versionDir)) { + return NO; + } pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); return state.current_version == hashStd || state.last_version == hashStd; }; diff --git a/scripts/test-ios-purge-restore.sh b/scripts/test-ios-purge-restore.sh index fb476abc..f9a806fa 100755 --- a/scripts/test-ios-purge-restore.sh +++ b/scripts/test-ios-purge-restore.sh @@ -35,6 +35,7 @@ build() { xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS $SANITIZE_FLAGS \ -I"$ROOT_DIR" -I"$destination" \ "$TEST_DIR/purge_restore_test.mm" "$ROOT_DIR/cpp/patch_core/state_core.cpp" \ + "$ROOT_DIR/cpp/patch_core/digest.cpp" \ "$ROOT_DIR/ios/RCTPushy/RCTPushyConfiguration.mm" \ -framework Foundation -o "$destination/purge_restore_test" } @@ -90,7 +91,8 @@ if [ "${VERIFY_REGRESSIONS:-0}" = "1" ]; then echo "[PASS] unsupported defaults $access rejected at compile time and runtime" done - for pair in late-activation:late_commit skip-reresolve:commit_before_signal ignore-reset:reset_wins; do + for pair in late-activation:late_commit skip-reresolve:commit_before_signal ignore-reset:reset_wins \ + trust-digestless:install_record_activation grandfather-recorded:switch_version_records; do variant="${pair%:*}" testcase="${pair#*:}" build "$variant" @@ -103,6 +105,8 @@ if [ "${VERIFY_REGRESSIONS:-0}" = "1" ]; then late-activation) expected="late round may persist its response but must not activate" ;; skip-reresolve) expected="must re-resolve B even when done signal is late" ;; ignore-reset) expected="stale generation must reject ALL commit writes" ;; + trust-digestless) expected="digest-less record must not activate" ;; + grandfather-recorded) expected="recorded current version must be verified" ;; esac if ! grep -F "[FAIL] $testcase: $expected" "$logfile"; then cat "$logfile" >&2 diff --git a/scripts/tests/ios-purge-restore/README.md b/scripts/tests/ios-purge-restore/README.md index 92a5e2f8..d44f7738 100644 --- a/scripts/tests/ios-purge-restore/README.md +++ b/scripts/tests/ios-purge-restore/README.md @@ -31,7 +31,9 @@ python3 -m unittest discover -s scripts/tests/ios-purge-restore -p test_extract. once across them; pod-internal `PUSHY_HIDDEN` definitions count like `static` ones). It copies the exact definitions (with `#line` locations) of `bundleURL`, `resolveLaunchBundleURL`, `restorePurgedLaunch`, `commitRoundWithGeneration`, -`resetToPackagedBundle`, their state/defaults helpers, and the relevant globals +`resetToPackagedBundle`, their state/defaults helpers, the install record +activation check (`PushyVerifyInstallForActivation`) with `switchVersion:error:`, +and the relevant globals into build-only `.inc` files. These are compiled unchanged with the actual `cpp/patch_core/state_core.cpp`. @@ -101,11 +103,15 @@ since process start. ## Negative controls -`VERIFY_REGRESSIONS=1` additionally compiles three generated-only mutants: +`VERIFY_REGRESSIONS=1` additionally compiles five generated-only mutants: - Remove the closed-window activation veto: `late_commit` must fail. - Return `NO` after timeout instead of re-resolving: `commit_before_signal` must fail. - Remove the reset-generation guard: `reset_wins` must fail. +- Trust a schema-1 install record without `bundleSha256`: + `install_record_activation` must fail. +- Grandfather current/last versions that do carry a record: + `switch_version_records` must fail. Each mutant must compile and fail its specific state assertion; unrelated compiler errors, crashes or deadlock-guard failures do not count as detection. diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index 5d4af402..eb210270 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -59,10 +59,19 @@ def method(source: str, name: str) -> tuple[int, str]: return definition(source, rf'^\+\s*\([^\n)]*\)\s*{re.escape(name)}\b', name) +def instance_method(source: str, name: str) -> tuple[int, str]: + return definition(source, rf'^-\s*\([^\n)]*\)\s*{re.escape(name)}\b', name) + + def exported_method(source: str, name: str) -> tuple[int, str]: return definition(source, rf'^RCT_EXPORT_METHOD\(\s*{re.escape(name)}\s*:', name) +def struct(source: str, name: str) -> tuple[int, str]: + line, text = definition(source, rf'^struct\s+{re.escape(name)}\b', name) + return line, text + ';' + + def declaration(source: str, name: str) -> tuple[int, str]: matches = list(re.finditer(rf'^{LINKAGE}\b[^\n;]*\b{re.escape(name)}\b[^\n;]*;', mask_literals(source), re.MULTILINE)) @@ -78,7 +87,7 @@ def declaration(source: str, name: str) -> tuple[int, str]: 'legacyParamPackageVersion', 'legacyParamBuildTime', 'paramLastVersion', 'paramCurrentVersion', 'paramIsFirstTime', 'paramIsFirstLoadOk', 'keyUuid', 'keyHashInfo', 'keyFirstLoadMarked', 'keyRolledBackMarked', - 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', + 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', 'VERSION_COMPLETE_FILE_NAME', 'ERROR_OPTIONS', 'pushyStateLock', 'ignoreRollback', 'pushyIsUsingBundleUrl', 'pushyResetGeneration', 'pushyNativeConfigGeneration', 'pushyLaunchVersion', 'pushyCrashHoldActive', 'pushyPurgeRestoreActive', 'pushyPurgeRestoreWindowOpen', @@ -91,8 +100,16 @@ def declaration(source: str, name: str) -> tuple[int, str]: # Canonical config equality (cross-platform contract), tested directly. 'PushyNormalizeEndpointBase', 'PushyJsonIsBool', 'PushyJsonEqual', 'PushyCanonicalUrlList', 'PushyCanonicalConfig', 'PushyConfigsEquivalent', + # Install record activation check (cpp/patch_core/install_record.h) and + # what switchVersion:error: needs around it. + 'PushyReadInstallRecord', 'PushyInstallRecordMatches', 'PushyHasInstallRecordFile', + 'PushyHasCompletedVersionAtPath', 'PushyStampFile', 'PushyVerifyInstallForActivation', + 'PushyStringIsBlank', 'PushyIsSafePathComponent', ] -MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset') +# Emitted ahead of HELPERS, which use them. +STRUCTS = ['PushyFileStamp'] +MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset', 'trust-digestless', + 'grandfather-recorded') def mutate(text: str, mutation: str) -> str: @@ -103,6 +120,11 @@ def mutate(text: str, mutation: str) -> str: 'ignore-reset': ( r'if \(pushyResetGeneration\.load\(\) != generation\)\s*\{\s*return;\s*\}', '(void)generation;'), + 'trust-digestless': ( + r'(expected\.length == 0\) \{\s*)return \[NSString[^;]*record mismatch[^;]*;', + r'\1return nil;'), + 'grandfather-recorded': ( + r'if \(PushyHasInstallRecordFile\(versionDir\)\)\s*\{\s*return NO;\s*\}', ''), } pattern, replacement = patterns[mutation] mutated, count = re.subn(pattern, replacement, text) @@ -137,16 +159,26 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non for name in SOURCES] restore = locate(sources, method, 'restorePurgedLaunch') commit = locate(sources, method, 'commitRoundWithGeneration') - if mutation == 'skip-reresolve': + helpers = [locate(sources, function, name) for name in HELPERS] + switch_version = locate(sources, instance_method, 'switchVersion') + if mutation == 'grandfather-recorded': + switch_version = (switch_version[0], switch_version[1], + mutate(switch_version[2], mutation)) + elif mutation == 'skip-reresolve': restore = (restore[0], restore[1], mutate(restore[2], mutation)) + elif mutation == 'trust-digestless': + index = HELPERS.index('PushyVerifyInstallForActivation') + path, line, text = helpers[index] + helpers[index] = (path, line, mutate(text, mutation)) elif mutation: commit = (commit[0], commit[1], mutate(commit[2], mutation)) sections = { 'globals.inc': [locate(sources, declaration, name) for name in GLOBALS], - 'helpers.inc': [locate(sources, function, name) for name in HELPERS], + 'helpers.inc': [locate(sources, struct, name) for name in STRUCTS] + helpers, 'pushy.inc': [locate(sources, method, 'bundleURL'), locate(sources, method, 'resolveLaunchBundleURL'), - locate(sources, exported_method, 'resetToPackagedBundle')], + locate(sources, exported_method, 'resetToPackagedBundle'), + switch_version], 'orchestrator.inc': [restore, commit], } output.mkdir(parents=True, exist_ok=True) diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index ed9fc2fd..b23e14b8 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -4,6 +4,8 @@ #import #import #import +#include +#include #include #include #include @@ -12,6 +14,8 @@ #include "cpp/patch_core/state_core.h" #include "cpp/patch_core/patch_core.h" #include "cpp/patch_core/error_codes.h" +#include "cpp/patch_core/digest.h" +#include "cpp/patch_core/install_record.h" #include "ios/RCTPushy/RCTPushyConfiguration.h" #undef TARGET_OS_TV @@ -30,6 +34,9 @@ static void TestLog(NSString *, ...) {} // Pod-internal linkage (RCTPushyInternal.h); one translation unit here. #define PUSHY_HIDDEN +// RCTPushy.mm's own #define, not extracted; InstallRecordActivation checks it +// against install_record.h. +#define VERSION_COMPLETE_FILE_NAME_LITERAL ".pushy-complete" #include "globals.inc" static const char *testName = "setup"; @@ -168,6 +175,7 @@ + (NSString *)packageVersion; + (NSString *)buildTime; + (NSString *)downloadDir; - (void)resetToPackagedBundle:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject; +- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; @end @interface RCTPushyOrchestrator : NSObject @@ -536,6 +544,147 @@ static void ConfigEquivalence(void) { std::printf("[PASS] config_equivalence: %lu shared vectors\n", (unsigned long)cases.count); } +// Two-phase install record (cpp/patch_core/install_record.h): the production +// activation check against real files. Schema 1 makes bundleSha256 mandatory; +// only the legacy EMPTY marker (SDK < 10.53) activates without a digest. +static NSString *Sha256Hex(NSData *data) { + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(data.bytes, (CC_LONG)data.length, digest); + NSMutableString *hex = [NSMutableString string]; + for (unsigned char byte : digest) { [hex appendFormat:@"%02x", byte]; } + return hex; +} + +static void InstallRecordActivation(void) { + Expect([VERSION_COMPLETE_FILE_NAME isEqualToString:@(pushy::install_record::kFileName)], + "record file name matches install_record.h"); + NSString *hash = @"0123456789abcdef"; + NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"pushy-install-record-%@", NSUUID.UUID.UUIDString]]; + Expect([NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES + attributes:nil error:nil], "temp dir"); + NSData *bundle = [@"var a = 1;" dataUsingEncoding:NSUTF8StringEncoding]; + Expect([bundle writeToFile:[dir stringByAppendingPathComponent:BUNDLE_FILE_NAME] atomically:YES], + "write bundle"); + NSString *marker = [dir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; + auto verify = ^NSString *(id record) { + NSData *data = [record isKindOfClass:NSData.class] + ? record : [NSJSONSerialization dataWithJSONObject:record options:0 error:nil]; + Expect([data writeToFile:marker atomically:YES], "write record"); + NSDictionary *verified = nil; + PushyFileStamp stamp; + return PushyVerifyInstallForActivation(dir, hash, &verified, &stamp); + }; + auto rejected = ^bool(NSString *reason, NSString *what) { + return reason != nil && [reason containsString:what]; + }; + NSNumber *schema = @(pushy::install_record::kSchema); + + Expect(verify([NSData data]) == nil, "legacy empty marker stays trusted"); + Expect(verify(@{@"schema": schema, @"versionHash": hash, @"bundleSha256": Sha256Hex(bundle)}) == nil, + "matching digest activates"); + Expect(verify(@{@"schema": schema, @"versionHash": hash, + @"bundleSha256": [Sha256Hex(bundle) uppercaseString]}) == nil, + "digest compare is case-insensitive"); + Expect(rejected(verify(@{@"schema": schema, @"versionHash": hash, + @"bundleSha256": Sha256Hex([NSData data])}), @"bundle digest mismatch"), + "tampered bundle must not activate"); + NSArray *digestless = @[ + @{@"schema": schema, @"versionHash": hash}, + @{@"schema": schema, @"versionHash": hash, @"bundleSha256": @""}, + @{@"schema": schema, @"versionHash": hash, @"bundleSha256": NSNull.null}, + @{@"schema": schema, @"versionHash": hash, @"bundleSha256": @123}, + @{@"schema": schema, @"versionHash": hash, @"artifactSha256": Sha256Hex(bundle)}, + ]; + for (NSDictionary *record in digestless) { + Expect(rejected(verify(record), @"completion record mismatch"), + "digest-less record must not activate"); + } + Expect(rejected(verify(@{@"schema": schema, @"versionHash": @"other", + @"bundleSha256": Sha256Hex(bundle)}), @"completion record mismatch"), + "record for another version must not activate"); + [NSFileManager.defaultManager removeItemAtPath:marker error:nil]; + NSDictionary *verified = nil; + PushyFileStamp stamp; + Expect(rejected(PushyVerifyInstallForActivation(dir, hash, &verified, &stamp), + @"no valid completion record"), + "missing record must not activate"); + [NSFileManager.defaultManager removeItemAtPath:dir error:nil]; +} + +// switchVersion:error: grandfathers current/last versions only when their +// directory has NO record file (activated before markers existed). A present +// record is verified like any other, so a digest-less schema-1 record cannot +// be re-activated through current/last; the empty legacy marker still can. +static void SwitchVersionRecords(void) { + SetUp(Order::CloseFirst); + NSString *hash = @"C"; + NSString *dir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; + Expect([NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES + attributes:nil error:nil], "create C"); + NSData *bundle = [@"C" dataUsingEncoding:NSUTF8StringEncoding]; + Expect([bundle writeToFile:[dir stringByAppendingPathComponent:BUNDLE_FILE_NAME] atomically:YES], + "write C bundle"); + NSString *marker = [dir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; + NSNumber *schema = @(pushy::install_record::kSchema); + auto setRecord = ^(NSDictionary *record) { + [NSFileManager.defaultManager removeItemAtPath:marker error:nil]; + if (record != nil) { + NSData *data = record.count == 0 + ? [NSData data] : [NSJSONSerialization dataWithJSONObject:record options:0 error:nil]; + Expect([data writeToFile:marker atomically:YES], "write C record"); + } + }; + auto pin = ^(const char *current, const char *last) { + PushyWithStateLock(^{ + pushy::state::State state = PushyStateFromDefaults(testDefaults); + state.current_version = current; + state.last_version = last; + PushyApplyStateToDefaults(testDefaults, state); + }); + }; + auto rejection = ^NSString *(void) { + NSError *error = nil; + if ([engine switchVersion:hash error:&error]) { + return nil; + } + Expect([error.domain isEqualToString:@(pushy::error_codes::kSwitchVersionFailed)], + "switch failures are SWITCH_VERSION_FAILED"); + return error.localizedDescription ?: @""; + }; + NSDictionary *digestless = @{@"schema": schema, @"versionHash": hash}; + + pin("C", ""); + setRecord(digestless); + NSString *reason = rejection(); + Expect(reason != nil && [reason containsString:@"completion record mismatch"], + "recorded current version must be verified"); + pin("A", "C"); + reason = rejection(); + Expect(reason != nil && [reason containsString:@"completion record mismatch"], + "recorded last version must be verified"); + + pin("A", ""); + setRecord(nil); + reason = rejection(); + Expect(reason != nil && [reason containsString:@"is incomplete"], + "markerless version outside current/last must not activate"); + + pin("A", "C"); + Expect(rejection() == nil, "markerless last version stays grandfathered"); + pin("C", "A"); + setRecord(@{}); + Expect(rejection() == nil, "empty legacy marker stays trusted"); + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(bundle.bytes, (CC_LONG)bundle.length, digest); + NSMutableString *hex = [NSMutableString string]; + for (unsigned char byte : digest) { [hex appendFormat:@"%02x", byte]; } + pin("C", "A"); + setRecord(@{@"schema": schema, @"versionHash": hash, @"bundleSha256": hex}); + Expect(rejection() == nil, "verified current version re-activates"); + Expect([NSFileManager.defaultManager removeItemAtPath:root error:NULL], "remove fixture files"); +} + int main(int argc, char **argv) { @autoreleasepool { // These intentional failures run in separate processes. Erasing the @@ -566,6 +715,8 @@ int main(int argc, char **argv) { {"late_launch", LateLaunch}, {"offline_skip", [] { Skipped(YES); }}, {"spent_budget_skip", [] { Skipped(NO); }}, + {"install_record_activation", InstallRecordActivation}, + {"switch_version_records", SwitchVersionRecords}, }; int ran = 0; for (const auto &test : cases) { diff --git a/scripts/tests/ios-purge-restore/test_extract.py b/scripts/tests/ios-purge-restore/test_extract.py index 0dd4c14d..38ff7f18 100644 --- a/scripts/tests/ios-purge-restore/test_extract.py +++ b/scripts/tests/ios-purge-restore/test_extract.py @@ -1,6 +1,7 @@ import unittest -from extract import declaration, exported_method, function, mask_literals, method, mutate +from extract import (declaration, exported_method, function, instance_method, mask_literals, + method, mutate, struct) class ExtractionTests(unittest.TestCase): @@ -23,6 +24,10 @@ def test_objc_declaration_and_definition(self): source = '+ (BOOL)restore:(id)value;\n+ (BOOL)restore:(id)value { return YES; }' self.assertEqual(method(source, 'restore'), (2, source.splitlines()[1])) + def test_objc_instance_method_is_not_a_class_method(self): + source = '+ (BOOL)switchVersion:(id)h { return NO; }\n- (BOOL)switchVersion:(id)h { return YES; }' + self.assertEqual(instance_method(source, 'switchVersion'), (2, source.splitlines()[1])) + def test_objc_no_argument_method(self): self.assertEqual(method('+ (id)bundleURL\n{ return nil; }', 'bundleURL')[0], 1) @@ -30,6 +35,11 @@ def test_exported_reset_method(self): source = 'RCT_EXPORT_METHOD(reset:(id)resolve\n rejecter:(id)reject)\n{ ^{ @"}"; }; }' self.assertEqual(exported_method(source, 'reset')[1], source) + def test_struct_definition_keeps_its_terminator(self): + source = 'struct Stamp;\nstruct Stamp {\n bool ok() const { return true; }\n};' + self.assertEqual(struct(source, 'Stamp'), + (2, 'struct Stamp {\n bool ok() const { return true; }\n};')) + def test_static_initializers_and_string_contents(self): self.assertEqual(declaration('static std::atomic active{false};', 'active')[1], 'static std::atomic active{false};') @@ -47,6 +57,10 @@ def test_missing_duplicate_or_unbalanced_source_fails_closed(self): def test_mutations_fail_on_source_drift(self): self.assertEqual(mutate('activation = nil;', 'late-activation'), '(void)activation;') self.assertEqual(mutate('return YES;', 'skip-reresolve'), 'return !timedOut;') + self.assertEqual( + mutate('expected.length == 0) {\n return [NSString stringWithFormat:@"%@ record mismatch.", h];', + 'trust-digestless'), + 'expected.length == 0) {\n return nil;') for source in ('', 'activation = nil; activation = nil;'): with self.assertRaises(ValueError): mutate(source, 'late-activation') diff --git a/src/__tests__/harmonyInstallRecord.test.ts b/src/__tests__/harmonyInstallRecord.test.ts new file mode 100644 index 00000000..6a8ec39c --- /dev/null +++ b/src/__tests__/harmonyInstallRecord.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; + +// Harmony InstallRecord.ts (ArkTS mirror of cpp/patch_core/install_record.h) +// imports @ohos modules, so it is evaluated with its imports stripped and its +// collaborators (file system, native digest, error factory) supplied as +// in-memory stubs. The on-device hypium suite (InstallRecord.test.ets) only +// runs in harmony CI; this keeps the record contract under `bun run test`. + +class PushyError extends Error { + code: string; + constructor(code: string, message: string) { + super(`[${code}] ${message}`); + this.code = code; + } +} + +interface InstallRecordModule { + buildInstallRecord( + versionHash: string, + bundleSha256: string, + artifactSha256: string + ): string; + expectedBundleSha256ForActivation( + record: Record | null, + versionHash: string + ): string; + verifyInstallForActivation( + versionDir: string, + versionHash: string, + bundlePath: string + ): Promise; +} + +function loadInstallRecord( + files: Map, + digests: Map +): InstallRecordModule { + const source = readFileSync( + new URL( + '../../harmony/pushy/src/main/ets/InstallRecord.ts', + import.meta.url + ), + 'utf8' + ) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export /gm, ''); + const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); + const fileIo = { + accessSync: (path: string) => files.has(path), + statSync: (path: string) => ({ size: files.get(path)?.length ?? 0 }), + readTextSync: (path: string) => files.get(path) ?? '', + }; + const NativePatchCore = { + sha256HexFileAsync: async (path: string) => { + const digest = digests.get(path); + if (digest === undefined) { + throw new Error('sha256HexFileAsync: cannot read file'); + } + return digest; + }, + }; + return runInNewContext( + `${js}\n({ buildInstallRecord, expectedBundleSha256ForActivation, verifyInstallForActivation });`, + { + fileIo, + NativePatchCore, + ERROR_DELTA_FAILED: 'PATCH_FAILED', + ERROR_SWITCH_VERSION_FAILED: 'SWITCH_VERSION_FAILED', + createPushyError: (code: string, message: string) => + new PushyError(code, message), + util: {}, + } + ); +} + +const HASH = 'abc'; +const DIR = `/data/_update/${HASH}`; +const RECORD = `${DIR}/.pushy-complete`; +const BUNDLE = `${DIR}/bundle.harmony.js`; +const DIGEST = 'ab'.repeat(32); + +function captureError(fn: () => unknown): PushyError { + try { + fn(); + } catch (e) { + return e as PushyError; + } + throw new Error('expected a throw'); +} + +describe('harmony install record writer', () => { + const { buildInstallRecord } = loadInstallRecord(new Map(), new Map()); + + test('writes the mandatory bundle digest', () => { + expect(JSON.parse(buildInstallRecord(HASH, DIGEST, 'ff'))).toEqual({ + schema: 1, + versionHash: HASH, + bundleSha256: DIGEST, + artifactSha256: 'ff', + }); + expect(JSON.parse(buildInstallRecord(HASH, DIGEST, ''))).toEqual({ + schema: 1, + versionHash: HASH, + bundleSha256: DIGEST, + }); + }); + + test('refuses a record without a bundle digest (install fails)', () => { + const error = captureError(() => buildInstallRecord(HASH, '', 'ff')); + expect(error.code).toBe('PATCH_FAILED'); + expect(error.message).toContain('no bundle digest'); + }); +}); + +describe('harmony install record activation', () => { + test('a legacy empty record stays trusted without hashing', async () => { + const files = new Map([[RECORD, '']]); + const { verifyInstallForActivation } = loadInstallRecord(files, new Map()); + await verifyInstallForActivation(DIR, HASH, BUNDLE); + }); + + test('a matching digest activates, case-insensitively', async () => { + const files = new Map([ + [ + RECORD, + JSON.stringify({ + schema: 1, + versionHash: HASH, + bundleSha256: DIGEST.toUpperCase(), + }), + ], + ]); + const { verifyInstallForActivation } = loadInstallRecord( + files, + new Map([[BUNDLE, DIGEST]]) + ); + await verifyInstallForActivation(DIR, HASH, BUNDLE); + }); + + test('a tampered bundle is rejected', async () => { + const files = new Map([ + [ + RECORD, + JSON.stringify({ schema: 1, versionHash: HASH, bundleSha256: DIGEST }), + ], + ]); + const { verifyInstallForActivation } = loadInstallRecord( + files, + new Map([[BUNDLE, 'cd'.repeat(32)]]) + ); + await expect(verifyInstallForActivation(DIR, HASH, BUNDLE)).rejects.toThrow( + 'bundle digest mismatch' + ); + }); + + for (const [name, record] of [ + ['no', { schema: 1, versionHash: HASH }], + ['an empty', { schema: 1, versionHash: HASH, bundleSha256: '' }], + ['a null', { schema: 1, versionHash: HASH, bundleSha256: null }], + ['a non-string', { schema: 1, versionHash: HASH, bundleSha256: 123 }], + [ + 'only an artifact', + { schema: 1, versionHash: HASH, artifactSha256: DIGEST }, + ], + ] as const) { + test(`a schema 1 record with ${name} bundle digest is rejected`, async () => { + const files = new Map([[RECORD, JSON.stringify(record)]]); + // The bundle hashes fine: rejection must come from the record alone. + const { verifyInstallForActivation, expectedBundleSha256ForActivation } = + loadInstallRecord(files, new Map([[BUNDLE, DIGEST]])); + const error = captureError(() => + expectedBundleSha256ForActivation(record, HASH) + ); + expect(error.code).toBe('SWITCH_VERSION_FAILED'); + expect(error.message).toContain('completion record mismatch'); + await expect( + verifyInstallForActivation(DIR, HASH, BUNDLE) + ).rejects.toThrow('completion record mismatch'); + }); + } + + test('a missing or foreign record is rejected', async () => { + const { verifyInstallForActivation } = loadInstallRecord( + new Map(), + new Map() + ); + await expect(verifyInstallForActivation(DIR, HASH, BUNDLE)).rejects.toThrow( + 'no valid completion record' + ); + const foreign = new Map([ + [ + RECORD, + JSON.stringify({ schema: 1, versionHash: 'def', bundleSha256: DIGEST }), + ], + ]); + await expect( + loadInstallRecord(foreign, new Map()).verifyInstallForActivation( + DIR, + HASH, + BUNDLE + ) + ).rejects.toThrow('completion record mismatch'); + }); +});