@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.
Files changed (111) hide show
  1. package/CodePush.podspec +47 -2
  2. package/README.md +118 -3
  3. package/THIRD-PARTY-NOTICES +96 -0
  4. package/android/app/build.gradle +26 -0
  5. package/android/app/proguard-rules.pro +5 -0
  6. package/android/app/src/main/cpp/CMakeLists.txt +38 -0
  7. package/android/app/src/main/cpp/binarypatch_jni.c +181 -0
  8. package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchAttempt.java +92 -0
  9. package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchResult.java +64 -0
  10. package/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +46 -11
  11. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +293 -0
  12. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +25 -0
  13. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +79 -88
  14. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +159 -4
  15. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +13 -0
  16. package/android/app/src/main/java/com/microsoft/codepush/react/HDiffPatchNative.java +52 -0
  17. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +393 -0
  18. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java +207 -0
  19. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +449 -0
  20. package/android/codepush-export.gradle +115 -0
  21. package/cli/commands/bundleCommand/bundleCodePush.ts +39 -7
  22. package/cli/commands/bundleCommand/index.ts +9 -1
  23. package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
  24. package/cli/commands/releaseCommand/index.test.ts +118 -0
  25. package/cli/commands/releaseCommand/index.ts +20 -2
  26. package/cli/commands/releaseCommand/release.test.ts +582 -0
  27. package/cli/commands/releaseCommand/release.ts +232 -15
  28. package/cli/dist/commands/bundleCommand/bundleCodePush.js +26 -7
  29. package/cli/dist/commands/bundleCommand/index.js +5 -1
  30. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
  31. package/cli/dist/commands/releaseCommand/index.js +8 -1
  32. package/cli/dist/commands/releaseCommand/index.test.js +96 -0
  33. package/cli/dist/commands/releaseCommand/release.js +165 -17
  34. package/cli/dist/commands/releaseCommand/release.test.js +367 -0
  35. package/cli/dist/functions/makeBinaryPatchBundle.js +248 -0
  36. package/cli/dist/functions/makeBinaryPatchBundle.test.js +321 -0
  37. package/cli/dist/functions/makeCodePushBundle.js +4 -2
  38. package/cli/dist/functions/runHermesEmitBinaryCommand.js +98 -27
  39. package/cli/dist/functions/runHermesEmitBinaryCommand.test.js +100 -0
  40. package/cli/dist/jest.globalSetup.js +38 -0
  41. package/cli/dist/utils/binaryPatch.js +86 -0
  42. package/cli/dist/utils/binaryPatch.test.js +190 -0
  43. package/cli/functions/makeBinaryPatchBundle.test.ts +410 -0
  44. package/cli/functions/makeBinaryPatchBundle.ts +367 -0
  45. package/cli/functions/makeCodePushBundle.ts +4 -2
  46. package/cli/functions/runHermesEmitBinaryCommand.test.ts +124 -0
  47. package/cli/functions/runHermesEmitBinaryCommand.ts +112 -26
  48. package/cli/jest.globalSetup.ts +43 -0
  49. package/cli/utils/binaryPatch.test.ts +234 -0
  50. package/cli/utils/binaryPatch.ts +103 -0
  51. package/cpp/binarypatch/binarypatch_zstd_decompressor.c +187 -0
  52. package/cpp/binarypatch/binarypatch_zstd_decompressor.h +47 -0
  53. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/checksum_plugin.h +52 -0
  54. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/hpatch_mt/hpatch_mt.h +147 -0
  55. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.c +3315 -0
  56. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.h +268 -0
  57. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_private.h +297 -0
  58. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_types.h +434 -0
  59. package/cpp/binarypatch/vendor/zstd/common/allocations.h +55 -0
  60. package/cpp/binarypatch/vendor/zstd/common/bits.h +205 -0
  61. package/cpp/binarypatch/vendor/zstd/common/bitstream.h +454 -0
  62. package/cpp/binarypatch/vendor/zstd/common/compiler.h +464 -0
  63. package/cpp/binarypatch/vendor/zstd/common/cpu.h +249 -0
  64. package/cpp/binarypatch/vendor/zstd/common/debug.c +30 -0
  65. package/cpp/binarypatch/vendor/zstd/common/debug.h +107 -0
  66. package/cpp/binarypatch/vendor/zstd/common/entropy_common.c +340 -0
  67. package/cpp/binarypatch/vendor/zstd/common/error_private.c +64 -0
  68. package/cpp/binarypatch/vendor/zstd/common/error_private.h +158 -0
  69. package/cpp/binarypatch/vendor/zstd/common/fse.h +625 -0
  70. package/cpp/binarypatch/vendor/zstd/common/fse_decompress.c +315 -0
  71. package/cpp/binarypatch/vendor/zstd/common/huf.h +277 -0
  72. package/cpp/binarypatch/vendor/zstd/common/mem.h +422 -0
  73. package/cpp/binarypatch/vendor/zstd/common/pool.c +371 -0
  74. package/cpp/binarypatch/vendor/zstd/common/pool.h +81 -0
  75. package/cpp/binarypatch/vendor/zstd/common/portability_macros.h +171 -0
  76. package/cpp/binarypatch/vendor/zstd/common/threading.c +182 -0
  77. package/cpp/binarypatch/vendor/zstd/common/threading.h +142 -0
  78. package/cpp/binarypatch/vendor/zstd/common/xxhash.c +18 -0
  79. package/cpp/binarypatch/vendor/zstd/common/xxhash.h +7094 -0
  80. package/cpp/binarypatch/vendor/zstd/common/zstd_common.c +48 -0
  81. package/cpp/binarypatch/vendor/zstd/common/zstd_deps.h +123 -0
  82. package/cpp/binarypatch/vendor/zstd/common/zstd_internal.h +324 -0
  83. package/cpp/binarypatch/vendor/zstd/common/zstd_trace.h +156 -0
  84. package/cpp/binarypatch/vendor/zstd/decompress/huf_decompress.c +1944 -0
  85. package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.c +244 -0
  86. package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.h +44 -0
  87. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress.c +2410 -0
  88. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.c +2209 -0
  89. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.h +73 -0
  90. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_internal.h +240 -0
  91. package/cpp/binarypatch/vendor/zstd/zstd.h +3206 -0
  92. package/cpp/binarypatch/vendor/zstd/zstd_errors.h +107 -0
  93. package/ios/CodePush/CodePush.h +58 -9
  94. package/ios/CodePush/{CodePush.m → CodePush.mm} +77 -63
  95. package/ios/CodePush/CodePushBinaryPatch.h +80 -0
  96. package/ios/CodePush/CodePushBinaryPatch.m +473 -0
  97. package/ios/CodePush/CodePushPackage.m +183 -2
  98. package/ios/CodePush/CodePushUpdateUtils.m +49 -1
  99. package/ios/CodePush.xcodeproj/project.pbxproj +6 -6
  100. package/package.json +28 -4
  101. package/react-native.config.js +3 -1
  102. package/scripts/binary-patch/build-hdiffpatch.sh +86 -0
  103. package/scripts/export-embedded-bundle.sh +76 -0
  104. package/src/CodePush.js +190 -96
  105. package/src/CodePush.test.js +382 -0
  106. package/src/native/NativeCodePush.ts +162 -0
  107. package/src/package-mixins.js +24 -11
  108. package/src/specs/NativeCodePush.ts +43 -0
  109. package/typings/react-native-code-push.d.ts +77 -0
  110. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushDialog.java +0 -102
  111. 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
- function getHermesCommand(): string {
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 result = childProcess.spawnSync('node', [
160
- '--print',
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 result = childProcess.spawnSync('node', [
173
- '--print',
174
- "require.resolve('hermes-compiler/package.json')",
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
+ }