@bravemobile/react-native-code-push 12.4.1 → 13.1.0-beta.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/CodePush.podspec +47 -2
- package/README.md +118 -3
- package/THIRD-PARTY-NOTICES +96 -0
- package/android/app/build.gradle +26 -0
- package/android/app/proguard-rules.pro +5 -0
- package/android/app/src/main/cpp/CMakeLists.txt +38 -0
- package/android/app/src/main/cpp/binarypatch_jni.c +181 -0
- package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchAttempt.java +92 -0
- package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchResult.java +64 -0
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +46 -11
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +293 -0
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +25 -0
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +79 -88
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +159 -4
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +13 -0
- package/android/app/src/main/java/com/microsoft/codepush/react/HDiffPatchNative.java +52 -0
- package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +393 -0
- package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java +207 -0
- package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +449 -0
- package/android/codepush-export.gradle +115 -0
- package/cli/commands/bundleCommand/bundleCodePush.ts +39 -7
- package/cli/commands/bundleCommand/index.ts +9 -1
- package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
- package/cli/commands/releaseCommand/index.test.ts +118 -0
- package/cli/commands/releaseCommand/index.ts +20 -2
- package/cli/commands/releaseCommand/release.test.ts +582 -0
- package/cli/commands/releaseCommand/release.ts +232 -15
- package/cli/dist/commands/bundleCommand/bundleCodePush.js +26 -7
- package/cli/dist/commands/bundleCommand/index.js +5 -1
- package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
- package/cli/dist/commands/releaseCommand/index.js +8 -1
- package/cli/dist/commands/releaseCommand/index.test.js +96 -0
- package/cli/dist/commands/releaseCommand/release.js +165 -17
- package/cli/dist/commands/releaseCommand/release.test.js +367 -0
- package/cli/dist/functions/makeBinaryPatchBundle.js +248 -0
- package/cli/dist/functions/makeBinaryPatchBundle.test.js +321 -0
- package/cli/dist/functions/makeCodePushBundle.js +4 -2
- package/cli/dist/functions/runHermesEmitBinaryCommand.js +98 -27
- package/cli/dist/functions/runHermesEmitBinaryCommand.test.js +100 -0
- package/cli/dist/jest.globalSetup.js +38 -0
- package/cli/dist/utils/binaryPatch.js +86 -0
- package/cli/dist/utils/binaryPatch.test.js +190 -0
- package/cli/functions/makeBinaryPatchBundle.test.ts +410 -0
- package/cli/functions/makeBinaryPatchBundle.ts +367 -0
- package/cli/functions/makeCodePushBundle.ts +4 -2
- package/cli/functions/runHermesEmitBinaryCommand.test.ts +124 -0
- package/cli/functions/runHermesEmitBinaryCommand.ts +112 -26
- package/cli/jest.globalSetup.ts +43 -0
- package/cli/utils/binaryPatch.test.ts +234 -0
- package/cli/utils/binaryPatch.ts +103 -0
- package/cpp/binarypatch/binarypatch_zstd_decompressor.c +187 -0
- package/cpp/binarypatch/binarypatch_zstd_decompressor.h +47 -0
- package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/checksum_plugin.h +52 -0
- package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/hpatch_mt/hpatch_mt.h +147 -0
- package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.c +3315 -0
- package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.h +268 -0
- package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_private.h +297 -0
- package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_types.h +434 -0
- package/cpp/binarypatch/vendor/zstd/common/allocations.h +55 -0
- package/cpp/binarypatch/vendor/zstd/common/bits.h +205 -0
- package/cpp/binarypatch/vendor/zstd/common/bitstream.h +454 -0
- package/cpp/binarypatch/vendor/zstd/common/compiler.h +464 -0
- package/cpp/binarypatch/vendor/zstd/common/cpu.h +249 -0
- package/cpp/binarypatch/vendor/zstd/common/debug.c +30 -0
- package/cpp/binarypatch/vendor/zstd/common/debug.h +107 -0
- package/cpp/binarypatch/vendor/zstd/common/entropy_common.c +340 -0
- package/cpp/binarypatch/vendor/zstd/common/error_private.c +64 -0
- package/cpp/binarypatch/vendor/zstd/common/error_private.h +158 -0
- package/cpp/binarypatch/vendor/zstd/common/fse.h +625 -0
- package/cpp/binarypatch/vendor/zstd/common/fse_decompress.c +315 -0
- package/cpp/binarypatch/vendor/zstd/common/huf.h +277 -0
- package/cpp/binarypatch/vendor/zstd/common/mem.h +422 -0
- package/cpp/binarypatch/vendor/zstd/common/pool.c +371 -0
- package/cpp/binarypatch/vendor/zstd/common/pool.h +81 -0
- package/cpp/binarypatch/vendor/zstd/common/portability_macros.h +171 -0
- package/cpp/binarypatch/vendor/zstd/common/threading.c +182 -0
- package/cpp/binarypatch/vendor/zstd/common/threading.h +142 -0
- package/cpp/binarypatch/vendor/zstd/common/xxhash.c +18 -0
- package/cpp/binarypatch/vendor/zstd/common/xxhash.h +7094 -0
- package/cpp/binarypatch/vendor/zstd/common/zstd_common.c +48 -0
- package/cpp/binarypatch/vendor/zstd/common/zstd_deps.h +123 -0
- package/cpp/binarypatch/vendor/zstd/common/zstd_internal.h +324 -0
- package/cpp/binarypatch/vendor/zstd/common/zstd_trace.h +156 -0
- package/cpp/binarypatch/vendor/zstd/decompress/huf_decompress.c +1944 -0
- package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.c +244 -0
- package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.h +44 -0
- package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress.c +2410 -0
- package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.c +2209 -0
- package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.h +73 -0
- package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_internal.h +240 -0
- package/cpp/binarypatch/vendor/zstd/zstd.h +3206 -0
- package/cpp/binarypatch/vendor/zstd/zstd_errors.h +107 -0
- package/ios/CodePush/CodePush.h +58 -9
- package/ios/CodePush/{CodePush.m → CodePush.mm} +77 -63
- package/ios/CodePush/CodePushBinaryPatch.h +80 -0
- package/ios/CodePush/CodePushBinaryPatch.m +473 -0
- package/ios/CodePush/CodePushPackage.m +183 -2
- package/ios/CodePush/CodePushUpdateUtils.m +49 -1
- package/ios/CodePush.xcodeproj/project.pbxproj +6 -6
- package/package.json +28 -4
- package/react-native.config.js +3 -1
- package/scripts/binary-patch/build-hdiffpatch.sh +86 -0
- package/scripts/export-embedded-bundle.sh +76 -0
- package/src/CodePush.js +190 -96
- package/src/CodePush.test.js +382 -0
- package/src/native/NativeCodePush.ts +162 -0
- package/src/package-mixins.js +24 -11
- package/src/specs/NativeCodePush.ts +43 -0
- package/typings/react-native-code-push.d.ts +77 -0
- package/android/app/src/main/java/com/microsoft/codepush/react/CodePushDialog.java +0 -102
- package/src/AlertAdapter.js +0 -24
|
@@ -5,8 +5,15 @@
|
|
|
5
5
|
import childProcess from "child_process";
|
|
6
6
|
import fs from "fs";
|
|
7
7
|
import path from "path";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
8
9
|
import shell from "shelljs";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Tells Hermes to lay the compiled bytecode out like an existing bundle's, which keeps
|
|
13
|
+
* the binary patch between the two small. Older compilers do not have the flag.
|
|
14
|
+
*/
|
|
15
|
+
const BASE_BYTECODE_FLAG = '-base-bytecode';
|
|
16
|
+
|
|
10
17
|
/**
|
|
11
18
|
* Run Hermes compile CLI command
|
|
12
19
|
*
|
|
@@ -14,6 +21,7 @@ import shell from "shelljs";
|
|
|
14
21
|
* @param outputPath {string} Path to output .hbc file
|
|
15
22
|
* @param sourcemapOutput {string} Path to output sourcemap file (Warning: if sourcemapOutput points to the outputPath, the sourcemap will be included in the CodePush bundle and increase the deployment size)
|
|
16
23
|
* @param extraHermesFlags {string[]} Additional options to pass to `hermesc` command
|
|
24
|
+
* @param projectRoot {string} Root directory of the target app project used to resolve the app's React Native module and locate the matching Hermes compiler. Defaults to the current working directory.
|
|
17
25
|
* @return {Promise<void>}
|
|
18
26
|
*/
|
|
19
27
|
export async function runHermesEmitBinaryCommand(
|
|
@@ -21,6 +29,7 @@ export async function runHermesEmitBinaryCommand(
|
|
|
21
29
|
outputPath: string,
|
|
22
30
|
sourcemapOutput: string,
|
|
23
31
|
extraHermesFlags: string[] = [],
|
|
32
|
+
projectRoot: string = process.cwd(),
|
|
24
33
|
): Promise<void> {
|
|
25
34
|
const hermesArgs: string[] = [
|
|
26
35
|
'-emit-binary',
|
|
@@ -37,10 +46,19 @@ export async function runHermesEmitBinaryCommand(
|
|
|
37
46
|
|
|
38
47
|
return new Promise<void>((resolve, reject) => {
|
|
39
48
|
try {
|
|
40
|
-
const hermesCommand = getHermesCommand();
|
|
49
|
+
const hermesCommand = getHermesCommand(projectRoot);
|
|
41
50
|
|
|
42
51
|
const disableAllWarningsArg = '-w';
|
|
43
|
-
shell.exec(`${hermesCommand} ${hermesArgs.join(' ')} ${disableAllWarningsArg}`);
|
|
52
|
+
const compileResult = shell.exec(`${hermesCommand} ${hermesArgs.join(' ')} ${disableAllWarningsArg}`);
|
|
53
|
+
if (compileResult.code !== 0) {
|
|
54
|
+
// Reported here rather than left to the missing .hbc file, so a rejected
|
|
55
|
+
// flag - a base bundle that is not Hermes bytecode, for instance - fails
|
|
56
|
+
// the release instead of silently producing an unaligned bundle.
|
|
57
|
+
const extraFlagsHint = extraHermesFlags.length > 0
|
|
58
|
+
? ` Additional options: ${extraHermesFlags.join(' ')}`
|
|
59
|
+
: '';
|
|
60
|
+
throw new Error(`"hermesc" command failed (exitCode=${compileResult.code}).${extraFlagsHint}`);
|
|
61
|
+
}
|
|
44
62
|
|
|
45
63
|
// Copy HBC bundle to overwrite JS bundle
|
|
46
64
|
const source = path.join(outputPath, bundleName + '.hbc');
|
|
@@ -58,7 +76,7 @@ export async function runHermesEmitBinaryCommand(
|
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
// compose-source-maps.js file path
|
|
61
|
-
const composeSourceMapsPath = getComposeSourceMapsPath();
|
|
79
|
+
const composeSourceMapsPath = getComposeSourceMapsPath(projectRoot);
|
|
62
80
|
if (composeSourceMapsPath === null) {
|
|
63
81
|
throw new Error('react-native compose-source-maps.js scripts is not found');
|
|
64
82
|
}
|
|
@@ -106,7 +124,48 @@ export async function runHermesEmitBinaryCommand(
|
|
|
106
124
|
});
|
|
107
125
|
}
|
|
108
126
|
|
|
109
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Builds the flags that align a compilation with the bundle already inside the app
|
|
129
|
+
* binary, so the binary patch between them stays small.
|
|
130
|
+
*
|
|
131
|
+
* When the app's Hermes compiler predates the flag the release still goes ahead
|
|
132
|
+
* without it: the patch is then computed against unaligned bytecode and is larger, but
|
|
133
|
+
* it is still a valid patch. A compiler that does accept the flag and fails is a
|
|
134
|
+
* different matter and fails the release, because the base input is then wrong.
|
|
135
|
+
*
|
|
136
|
+
* @param baseBundlePath {string} JS bundle from the target binary to align against
|
|
137
|
+
* @param projectRoot {string} Root directory of the target app project, used to locate its Hermes compiler
|
|
138
|
+
* @return {string[]} Flags to pass to `runHermesEmitBinaryCommand` as `extraHermesFlags`
|
|
139
|
+
*/
|
|
140
|
+
export function resolveBaseBytecodeHermesFlags(baseBundlePath: string, projectRoot: string = process.cwd()): string[] {
|
|
141
|
+
if (!hermesSupportsBaseBytecode(projectRoot)) {
|
|
142
|
+
console.warn(
|
|
143
|
+
`warn: The Hermes compiler of this app does not support "${BASE_BYTECODE_FLAG}". ` +
|
|
144
|
+
'Compiling without it, which makes the binary patch larger.',
|
|
145
|
+
);
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return [BASE_BYTECODE_FLAG, baseBundlePath];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Whether a `hermesc --help` output advertises the base bytecode flag. */
|
|
153
|
+
export function helpOutputSupportsBaseBytecode(helpOutput: string): boolean {
|
|
154
|
+
return helpOutput.includes(BASE_BYTECODE_FLAG);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hermesSupportsBaseBytecode(projectRoot: string): boolean {
|
|
158
|
+
const hermesCommand = getHermesCommand(projectRoot);
|
|
159
|
+
const result = childProcess.spawnSync(hermesCommand, ['--help'], { encoding: 'utf8' });
|
|
160
|
+
|
|
161
|
+
if (result.error) {
|
|
162
|
+
throw new Error(`failed to run "${hermesCommand} --help": ${result.error.message}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return helpOutputSupportsBaseBytecode(`${result.stdout ?? ''}${result.stderr ?? ''}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getHermesCommand(projectRoot: string): string {
|
|
110
169
|
const fileExists = (file: string): boolean => {
|
|
111
170
|
try {
|
|
112
171
|
return fs.statSync(file).isFile();
|
|
@@ -115,7 +174,7 @@ function getHermesCommand(): string {
|
|
|
115
174
|
}
|
|
116
175
|
};
|
|
117
176
|
|
|
118
|
-
const hermescExecutable = path.join(getHermesCompilerPath(), getHermesOSBin(), getHermesOSExe());
|
|
177
|
+
const hermescExecutable = path.join(getHermesCompilerPath(projectRoot), getHermesOSBin(), getHermesOSExe());
|
|
119
178
|
if (fileExists(hermescExecutable)) {
|
|
120
179
|
return hermescExecutable;
|
|
121
180
|
}
|
|
@@ -146,48 +205,75 @@ function getHermesOSExe(): string {
|
|
|
146
205
|
}
|
|
147
206
|
}
|
|
148
207
|
|
|
149
|
-
function getComposeSourceMapsPath(): string | null {
|
|
208
|
+
function getComposeSourceMapsPath(projectRoot: string): string | null {
|
|
150
209
|
// detect if compose-source-maps.js script exists
|
|
151
|
-
const composeSourceMaps = path.join(getReactNativePackagePath(), 'scripts', 'compose-source-maps.js');
|
|
210
|
+
const composeSourceMaps = path.join(getReactNativePackagePath(projectRoot), 'scripts', 'compose-source-maps.js');
|
|
152
211
|
if (fs.existsSync(composeSourceMaps)) {
|
|
153
212
|
return composeSourceMaps;
|
|
154
213
|
}
|
|
155
214
|
return null;
|
|
156
215
|
}
|
|
157
216
|
|
|
158
|
-
function getReactNativePackagePath(): string {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
"require.resolve('react-native/package.json')",
|
|
162
|
-
]);
|
|
163
|
-
const packagePath = path.dirname(result.stdout.toString());
|
|
164
|
-
if (result.status === 0 && directoryExistsSync(packagePath)) {
|
|
217
|
+
function getReactNativePackagePath(projectRoot: string): string {
|
|
218
|
+
const packagePath = resolvePackageRoot(projectRoot, 'react-native');
|
|
219
|
+
if (packagePath !== null) {
|
|
165
220
|
return packagePath;
|
|
166
221
|
}
|
|
167
222
|
|
|
168
|
-
return path.join('node_modules', 'react-native');
|
|
223
|
+
return path.join(projectRoot, 'node_modules', 'react-native');
|
|
169
224
|
}
|
|
170
225
|
|
|
171
|
-
function getHermescDirPathInHermesCompilerPackage() {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const packagePath = path.dirname(result.stdout.toString());
|
|
177
|
-
const hermescDirPath = path.join(packagePath, 'hermesc');
|
|
178
|
-
if (result.status === 0 && directoryExistsSync(hermescDirPath)) {
|
|
226
|
+
function getHermescDirPathInHermesCompilerPackage(projectRoot: string) {
|
|
227
|
+
const reactNativePackagePath = getReactNativePackagePath(projectRoot);
|
|
228
|
+
const hermescDirPath = path.join(path.dirname(reactNativePackagePath), 'hermes-compiler', 'hermesc');
|
|
229
|
+
|
|
230
|
+
if (directoryExistsSync(hermescDirPath)) {
|
|
179
231
|
return hermescDirPath;
|
|
180
232
|
}
|
|
233
|
+
|
|
181
234
|
return null;
|
|
182
235
|
}
|
|
183
236
|
|
|
184
|
-
function getHermesCompilerPath() {
|
|
185
|
-
const hermescDirPath = getHermescDirPathInHermesCompilerPackage();
|
|
237
|
+
function getHermesCompilerPath(projectRoot: string) {
|
|
238
|
+
const hermescDirPath = getHermescDirPathInHermesCompilerPackage(projectRoot);
|
|
186
239
|
if (hermescDirPath) {
|
|
187
240
|
// Since react-native 0.83, Hermes compiler executables are in 'hermes-compiler' package
|
|
188
241
|
return hermescDirPath
|
|
189
242
|
} else {
|
|
190
|
-
return path.join(getReactNativePackagePath(), 'sdks', 'hermesc');
|
|
243
|
+
return path.join(getReactNativePackagePath(projectRoot), 'sdks', 'hermesc');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolvePackageRoot(projectRoot: string, packageName: string): string | null {
|
|
248
|
+
try {
|
|
249
|
+
const projectRequire = createRequire(path.join(projectRoot, 'package.json'));
|
|
250
|
+
const resolvedPath = projectRequire.resolve(packageName);
|
|
251
|
+
return findPackageRoot(packageName, resolvedPath);
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function findPackageRoot(packageName: string, resolvedPath: string): string | null {
|
|
258
|
+
let currentPath = path.dirname(resolvedPath);
|
|
259
|
+
|
|
260
|
+
while (true) {
|
|
261
|
+
const packageJsonPath = path.join(currentPath, 'package.json');
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { name?: string };
|
|
265
|
+
if (packageJson.name === packageName) {
|
|
266
|
+
return currentPath;
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
// Continue traversing upward until the package root is found.
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const parentPath = path.dirname(currentPath);
|
|
273
|
+
if (parentPath === currentPath) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
currentPath = parentPath;
|
|
191
277
|
}
|
|
192
278
|
}
|
|
193
279
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds hdiffz/hpatchz once, before any test worker starts.
|
|
7
|
+
*
|
|
8
|
+
* Several suites generate and apply real patches, which is the only way to prove the
|
|
9
|
+
* committed patch format is what every applier understands. Provisioning the tools here
|
|
10
|
+
* rather than in each suite keeps a clean checkout - and CI - free of a manual setup
|
|
11
|
+
* step, without letting two workers run the same build at the same time.
|
|
12
|
+
*
|
|
13
|
+
* The build script installs into `HDIFFPATCH_TOOLS_DIR` when it is set and into
|
|
14
|
+
* `<repo>/.hdiffpatch-tools` otherwise, which is where the CLI looks for the tools. It
|
|
15
|
+
* is a no-op when they are already there, so the check below only decides whether to
|
|
16
|
+
* warn that this run has a multi-minute build ahead of it.
|
|
17
|
+
*
|
|
18
|
+
* Note that this module is loaded outside the jest module resolver, so it cannot import
|
|
19
|
+
* from the CLI sources.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const REPO_ROOT = path.join(__dirname, '..');
|
|
23
|
+
const BUILD_SCRIPT_PATH = path.join(REPO_ROOT, 'scripts', 'binary-patch', 'build-hdiffpatch.sh');
|
|
24
|
+
const DEFAULT_TOOLS_DIR = path.join(REPO_ROOT, '.hdiffpatch-tools');
|
|
25
|
+
|
|
26
|
+
export default function ensureBinaryPatchTools(): void {
|
|
27
|
+
const toolsDir = process.env.HDIFFPATCH_TOOLS_DIR || DEFAULT_TOOLS_DIR;
|
|
28
|
+
const alreadyInstalled = ['hdiffz', 'hpatchz'].every((tool) => fs.existsSync(path.join(toolsDir, tool)));
|
|
29
|
+
|
|
30
|
+
if (!alreadyInstalled) {
|
|
31
|
+
// Announced because a first build clones and compiles HDiffPatch, which takes
|
|
32
|
+
// minutes; a silent wait would look like a hang.
|
|
33
|
+
console.log(`Building hdiffz/hpatchz into ${toolsDir} (first run only)`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result = spawnSync(BUILD_SCRIPT_PATH, { encoding: 'utf8' });
|
|
37
|
+
if (result.error) {
|
|
38
|
+
throw new Error(`failed to run ${BUILD_SCRIPT_PATH}: ${result.error.message}`);
|
|
39
|
+
}
|
|
40
|
+
if (result.status !== 0) {
|
|
41
|
+
throw new Error(`${BUILD_SCRIPT_PATH} failed:\n${result.stdout ?? ''}${result.stderr ?? ''}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
|
|
7
|
+
import { applyPatch, generatePatch, resolveBinaryPatchTool } from "./binaryPatch.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Locks the binary patch codec contract: which bytes a patch restores, and which
|
|
11
|
+
* kinds of broken input the appliers do and do not catch.
|
|
12
|
+
*
|
|
13
|
+
* Everything here runs against real binaries and real bytes - hdiffz/hpatchz and a
|
|
14
|
+
* host build of the native applier - because the whole point is to prove that the
|
|
15
|
+
* committed patch format really is what every applier understands.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Building the applier from source takes a while on a cold machine. */
|
|
19
|
+
const BUILD_APPLIER_TIMEOUT_MS = 5 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
function findRepoRoot(): string {
|
|
22
|
+
let dir = process.cwd();
|
|
23
|
+
for (;;) {
|
|
24
|
+
if (fs.existsSync(path.join(dir, "scripts", "binary-patch", "build-hdiffpatch.sh"))) {
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
const parent = path.dirname(dir);
|
|
28
|
+
if (parent === dir) {
|
|
29
|
+
throw new Error(`cannot locate the repository root from ${process.cwd()}`);
|
|
30
|
+
}
|
|
31
|
+
dir = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const repoRoot = findRepoRoot();
|
|
36
|
+
const fixtureDir = path.join(repoRoot, "cli", "fixtures", "binary-patch");
|
|
37
|
+
const baseFixture = path.join(fixtureDir, "base.bundle");
|
|
38
|
+
const targetFixture = path.join(fixtureDir, "target.bundle");
|
|
39
|
+
const patchFixture = path.join(fixtureDir, "update.patch");
|
|
40
|
+
/**
|
|
41
|
+
* One shared tree builds the applier for both platforms: iOS pulls it in through the
|
|
42
|
+
* podspec at the repository root, Android through externalNativeBuild.
|
|
43
|
+
*/
|
|
44
|
+
const applierDir = path.join(repoRoot, "cpp", "binarypatch");
|
|
45
|
+
|
|
46
|
+
let workDir: string;
|
|
47
|
+
|
|
48
|
+
function sha256(filePath: string): string {
|
|
49
|
+
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function workPath(name: string): string {
|
|
53
|
+
return path.join(workDir, name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Writes a copy of `sourcePath` with `mutate` applied to its bytes. */
|
|
57
|
+
function writeMutatedCopy(sourcePath: string, name: string, mutate: (bytes: Buffer) => Buffer): string {
|
|
58
|
+
const destination = workPath(name);
|
|
59
|
+
fs.writeFileSync(destination, mutate(fs.readFileSync(sourcePath)));
|
|
60
|
+
return destination;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cCompiler(): string {
|
|
64
|
+
return process.env.CC || "cc";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasCCompiler(): boolean {
|
|
68
|
+
return spawnSync(cCompiler(), ["--version"], { stdio: "ignore" }).status === 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
// hdiffz/hpatchz are provisioned for the whole run by the jest global setup.
|
|
73
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "codepush-binary-patch-"));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(() => {
|
|
77
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("generatePatch/applyPatch", () => {
|
|
81
|
+
it("restores the target bytes from a freshly generated patch", () => {
|
|
82
|
+
const generatedPatch = workPath("generated.patch");
|
|
83
|
+
const restored = workPath("restored-from-generated.bundle");
|
|
84
|
+
|
|
85
|
+
generatePatch(baseFixture, targetFixture, generatedPatch);
|
|
86
|
+
applyPatch(baseFixture, generatedPatch, restored);
|
|
87
|
+
|
|
88
|
+
expect(fs.statSync(generatedPatch).size).toBeGreaterThan(0);
|
|
89
|
+
expect(sha256(restored)).toBe(sha256(targetFixture));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("restores the target bytes from the committed patch fixture", () => {
|
|
93
|
+
const restored = workPath("restored-from-fixture.bundle");
|
|
94
|
+
|
|
95
|
+
applyPatch(baseFixture, patchFixture, restored);
|
|
96
|
+
|
|
97
|
+
expect(sha256(restored)).toBe(sha256(targetFixture));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("reports a missing patch tool with a message that says how to get it", () => {
|
|
101
|
+
const previous = process.env.HDIFFPATCH_TOOLS_DIR;
|
|
102
|
+
process.env.HDIFFPATCH_TOOLS_DIR = workPath("no-such-dir");
|
|
103
|
+
try {
|
|
104
|
+
expect(() => resolveBinaryPatchTool("hdiffz")).toThrow(/build-hdiffpatch\.sh/);
|
|
105
|
+
} finally {
|
|
106
|
+
if (previous === undefined) {
|
|
107
|
+
delete process.env.HDIFFPATCH_TOOLS_DIR;
|
|
108
|
+
} else {
|
|
109
|
+
process.env.HDIFFPATCH_TOOLS_DIR = previous;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("applyPatch failure modes", () => {
|
|
116
|
+
it("fails on a patch with a corrupted header", () => {
|
|
117
|
+
const corrupted = writeMutatedCopy(patchFixture, "corrupt-header.patch", (bytes) => {
|
|
118
|
+
// Byte 0 is inside the patch type string, which the header parser reads first.
|
|
119
|
+
bytes[0] ^= 0xff;
|
|
120
|
+
return bytes;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(() => applyPatch(baseFixture, corrupted, workPath("out-corrupt-header.bundle"))).toThrow(/hpatchz/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("fails on a truncated patch", () => {
|
|
127
|
+
const truncated = writeMutatedCopy(patchFixture, "truncated.patch", (bytes) =>
|
|
128
|
+
bytes.subarray(0, Math.floor(bytes.length / 2)),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(() => applyPatch(baseFixture, truncated, workPath("out-truncated.bundle"))).toThrow(/hpatchz/);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A compressed diff carries no checksum of the base data, and the zstd streams
|
|
137
|
+
* inside it are written without content checksums, so an applier cannot tell every
|
|
138
|
+
* kind of wrong input apart from a legitimate one. These tests pin that down: the
|
|
139
|
+
* apply step may report success, but it never produces the target bytes. Callers
|
|
140
|
+
* must therefore verify the base and target hashes themselves rather than treating
|
|
141
|
+
* a successful apply as proof of a correct result.
|
|
142
|
+
*/
|
|
143
|
+
describe("errors the codec cannot detect", () => {
|
|
144
|
+
it("never produces the target bytes when a byte inside the compressed diff data is corrupted", () => {
|
|
145
|
+
const corrupted = writeMutatedCopy(patchFixture, "corrupt-body.patch", (bytes) => {
|
|
146
|
+
bytes[Math.floor(bytes.length / 2)] ^= 0xff;
|
|
147
|
+
return bytes;
|
|
148
|
+
});
|
|
149
|
+
const restored = workPath("out-corrupt-body.bundle");
|
|
150
|
+
|
|
151
|
+
// Whether the corruption is reported depends on where it lands: a flipped bit
|
|
152
|
+
// inside a zstd literals block decodes to different bytes without any error,
|
|
153
|
+
// while one that invalidates a block header does fail. Either way the result
|
|
154
|
+
// is not the target, which is all the caller can rely on.
|
|
155
|
+
let restoredHash: string | null = null;
|
|
156
|
+
try {
|
|
157
|
+
applyPatch(baseFixture, corrupted, restored);
|
|
158
|
+
restoredHash = sha256(restored);
|
|
159
|
+
} catch {
|
|
160
|
+
restoredHash = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(restoredHash).not.toBe(sha256(targetFixture));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("applies but produces wrong bytes when the base has the expected size and different content", () => {
|
|
167
|
+
const wrongBase = writeMutatedCopy(baseFixture, "wrong-base.bundle", (bytes) => {
|
|
168
|
+
bytes[1000] ^= 0xff;
|
|
169
|
+
return bytes;
|
|
170
|
+
});
|
|
171
|
+
const restored = workPath("out-wrong-base.bundle");
|
|
172
|
+
|
|
173
|
+
applyPatch(wrongBase, patchFixture, restored);
|
|
174
|
+
|
|
175
|
+
expect(fs.statSync(wrongBase).size).toBe(fs.statSync(baseFixture).size);
|
|
176
|
+
expect(sha256(restored)).not.toBe(sha256(targetFixture));
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The native appliers on Android and iOS are built from the vendored sources, so a
|
|
182
|
+
* host build of the same sources is what proves they agree with hdiffz/hpatchz.
|
|
183
|
+
*/
|
|
184
|
+
const describeNativeApplier = hasCCompiler() ? describe : describe.skip;
|
|
185
|
+
|
|
186
|
+
describeNativeApplier("host build of the native applier", () => {
|
|
187
|
+
let hostApplier: string;
|
|
188
|
+
|
|
189
|
+
const runHostApplier = (oldPath: string, patch: string, outPath: string): number | null => {
|
|
190
|
+
const result = spawnSync(hostApplier, [oldPath, patch, outPath], { encoding: "utf8" });
|
|
191
|
+
return result.status;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
beforeAll(() => {
|
|
195
|
+
hostApplier = workPath("apply_patch_host");
|
|
196
|
+
const script = path.join(applierDir, "host", "build.sh");
|
|
197
|
+
const result = spawnSync(script, [hostApplier], { encoding: "utf8", timeout: BUILD_APPLIER_TIMEOUT_MS });
|
|
198
|
+
if (result.status !== 0) {
|
|
199
|
+
throw new Error(`${script} failed:\n${result.stdout ?? ""}${result.stderr ?? ""}`);
|
|
200
|
+
}
|
|
201
|
+
}, BUILD_APPLIER_TIMEOUT_MS);
|
|
202
|
+
|
|
203
|
+
it("restores the same target bytes as the CLI applier", () => {
|
|
204
|
+
const restored = workPath("out-host.bundle");
|
|
205
|
+
|
|
206
|
+
expect(runHostApplier(baseFixture, patchFixture, restored)).toBe(0);
|
|
207
|
+
expect(sha256(restored)).toBe(sha256(targetFixture));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("reports a corrupted patch header as exit code 3", () => {
|
|
211
|
+
const corrupted = writeMutatedCopy(patchFixture, "host-corrupt-header.patch", (bytes) => {
|
|
212
|
+
bytes[0] ^= 0xff;
|
|
213
|
+
return bytes;
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(runHostApplier(baseFixture, corrupted, workPath("out-host-corrupt-header.bundle"))).toBe(3);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("reports a base of unexpected size as exit code 5", () => {
|
|
220
|
+
const shortBase = writeMutatedCopy(baseFixture, "host-short-base.bundle", (bytes) =>
|
|
221
|
+
bytes.subarray(0, bytes.length - 16),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(runHostApplier(shortBase, patchFixture, workPath("out-host-short-base.bundle"))).toBe(5);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("reports a truncated patch as exit code 6", () => {
|
|
228
|
+
const truncated = writeMutatedCopy(patchFixture, "host-truncated.patch", (bytes) =>
|
|
229
|
+
bytes.subarray(0, Math.floor(bytes.length / 2)),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(runHostApplier(baseFixture, truncated, workPath("out-host-truncated.bundle"))).toBe(6);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary patch codec for CodePush updates.
|
|
3
|
+
*
|
|
4
|
+
* A binary patch update carries only the difference between the bundle that is
|
|
5
|
+
* already inside the app binary and the bundle a release wants to run, instead of
|
|
6
|
+
* the whole bundle. Patches are produced by HDiffPatch's `hdiffz` and applied on
|
|
7
|
+
* device by the native appliers; `hpatchz` is the reference applier used here so a
|
|
8
|
+
* patch can be verified right after it is produced.
|
|
9
|
+
*
|
|
10
|
+
* The tool options below are part of the format contract, not tuning knobs:
|
|
11
|
+
* -m-6 the base bundle is held in memory while patching, which is what
|
|
12
|
+
* the native appliers do, and match score 6 suits bytecode
|
|
13
|
+
* -c-zstd-21-24 zstd is the only decompressor the native appliers link
|
|
14
|
+
* -f overwrite the output file if it already exists
|
|
15
|
+
* Changing them produces patches the native appliers cannot read.
|
|
16
|
+
*
|
|
17
|
+
* A patch contains no checksum of the base data, and its zstd streams carry no
|
|
18
|
+
* content checksums, so a successful apply is not proof of a correct result: a base
|
|
19
|
+
* of the right size but the wrong content, or a corrupted patch body, can both apply
|
|
20
|
+
* "successfully" and yield wrong bytes. Callers must verify the base and target
|
|
21
|
+
* hashes themselves.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from "child_process";
|
|
25
|
+
import fs from "fs";
|
|
26
|
+
import path from "path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Identifies the patch format so a client can refuse a patch it cannot apply.
|
|
30
|
+
* Bump the format version whenever the tool options above change.
|
|
31
|
+
*/
|
|
32
|
+
export const BINARY_PATCH_ALGORITHM = 'hdiffpatch-m-zstd';
|
|
33
|
+
export const BINARY_PATCH_FORMAT_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
export type BinaryPatchTool = 'hdiffz' | 'hpatchz';
|
|
36
|
+
|
|
37
|
+
const HDIFFZ_OPTIONS = ['-f', '-m-6', '-c-zstd-21-24'];
|
|
38
|
+
const HPATCHZ_OPTIONS = ['-f', '-m'];
|
|
39
|
+
|
|
40
|
+
const TOOLS_DIR_ENV_NAME = 'HDIFFPATCH_TOOLS_DIR';
|
|
41
|
+
const TOOLS_DIR_NAME = '.hdiffpatch-tools';
|
|
42
|
+
const BUILD_SCRIPT_PATH = 'scripts/binary-patch/build-hdiffpatch.sh';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Finds the hdiffz/hpatchz executable, looking at `HDIFFPATCH_TOOLS_DIR` first and
|
|
46
|
+
* then at a `.hdiffpatch-tools` directory in the working directory or any directory
|
|
47
|
+
* above it. The tools are built from source rather than installed as a package
|
|
48
|
+
* dependency, so the error explains how to get them.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveBinaryPatchTool(tool: BinaryPatchTool): string {
|
|
51
|
+
const configuredDir = process.env[TOOLS_DIR_ENV_NAME];
|
|
52
|
+
if (configuredDir) {
|
|
53
|
+
const configured = path.join(configuredDir, tool);
|
|
54
|
+
if (fs.existsSync(configured)) {
|
|
55
|
+
return configured;
|
|
56
|
+
}
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${TOOLS_DIR_ENV_NAME} is set to '${configuredDir}' but it does not contain '${tool}'. ` +
|
|
59
|
+
`Build the tools with '${BUILD_SCRIPT_PATH}'.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let directory = path.resolve(process.cwd());
|
|
64
|
+
for (;;) {
|
|
65
|
+
const candidate = path.join(directory, TOOLS_DIR_NAME, tool);
|
|
66
|
+
if (fs.existsSync(candidate)) {
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
const parent = path.dirname(directory);
|
|
70
|
+
if (parent === directory) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
directory = parent;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
throw new Error(
|
|
77
|
+
`'${tool}' not found in any '${TOOLS_DIR_NAME}' directory at or above '${process.cwd()}'. ` +
|
|
78
|
+
`Build it with '${BUILD_SCRIPT_PATH}', or set ${TOOLS_DIR_ENV_NAME} to a directory that contains it.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Writes the patch that turns the bundle at `basePath` into the one at `targetPath`. */
|
|
83
|
+
export function generatePatch(basePath: string, targetPath: string, patchPath: string): void {
|
|
84
|
+
runTool('hdiffz', [...HDIFFZ_OPTIONS, basePath, targetPath, patchPath]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Writes the bundle that `patchPath` produces from the bundle at `basePath`. */
|
|
88
|
+
export function applyPatch(basePath: string, patchPath: string, outputPath: string): void {
|
|
89
|
+
runTool('hpatchz', [...HPATCHZ_OPTIONS, basePath, patchPath, outputPath]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function runTool(tool: BinaryPatchTool, args: string[]): void {
|
|
93
|
+
const executable = resolveBinaryPatchTool(tool);
|
|
94
|
+
const result = spawnSync(executable, args, { encoding: 'utf8' });
|
|
95
|
+
|
|
96
|
+
if (result.error) {
|
|
97
|
+
throw new Error(`failed to run ${tool}: ${result.error.message}`);
|
|
98
|
+
}
|
|
99
|
+
if (result.status !== 0) {
|
|
100
|
+
const reason = result.status === null ? `signal ${result.signal}` : `exit code ${result.status}`;
|
|
101
|
+
throw new Error(`${tool} failed with ${reason}\n${result.stdout ?? ''}${result.stderr ?? ''}`);
|
|
102
|
+
}
|
|
103
|
+
}
|