Skip to content

Commit 12aba20

Browse files
authored
fix(hermes-base): audit semantic operands and harden fallback (#84)
Preserve semantic operands with a second raw HBC audit, fail closed on unverifiable layouts, and bound compiler and verifier subprocesses. Fix speculative sourcemap rejection handling, zero-length Static Hermes functions, and debug-output cleanup after timeout or spawn failure. Add real HBC96/HBC98 and Metro bundle regression coverage, including compiler patch16/patch17 and Node 18 checks.
1 parent a79e3db commit 12aba20

18 files changed

Lines changed: 1941 additions & 81 deletions

.github/workflows/test.yml

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@ jobs:
102102
- name: Load every built module and run the offline commands
103103
run: node scripts/smoke-lib.js
104104

105+
- name: Check late abort errors on the oldest supported Node.js
106+
run: >-
107+
node tests/fixtures/hermes-async-check.cjs
108+
'{"operation":"abort","modulePath":"./lib/utils/hermes-base.js"}'
109+
110+
- name: Check debug-output failure cleanup on Node 18
111+
env:
112+
HERMES_TEST_NODE: node
113+
run: bun test tests/hermes-blockers.test.ts
114+
105115
publish-dry-run:
106116
runs-on: blacksmith-4vcpu-ubuntu-2404
107117
timeout-minutes: 10
@@ -134,3 +144,80 @@ jobs:
134144
env:
135145
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
136146
run: npm publish --dry-run --access public --tag dry-run
147+
148+
hermes-integration:
149+
name: hermes-hbc-${{ matrix.hbc }}${{ matrix.suffix }}
150+
runs-on: blacksmith-4vcpu-ubuntu-2404
151+
timeout-minutes: 15
152+
strategy:
153+
fail-fast: false
154+
matrix:
155+
include:
156+
- hbc: 96
157+
suffix: ""
158+
package: react-native@0.77.3
159+
directory: react-native
160+
executable: sdks/hermesc/linux64-bin/hermesc
161+
- hbc: 98
162+
suffix: ""
163+
package: hermes-compiler@250829098.0.16
164+
directory: hermes-compiler
165+
executable: hermesc/linux64-bin/hermesc
166+
- hbc: 98
167+
suffix: -patch17
168+
package: hermes-compiler@250829098.0.17
169+
directory: hermes-compiler
170+
executable: hermesc/linux64-bin/hermesc
171+
steps:
172+
- uses: actions/checkout@v7
173+
with:
174+
persist-credentials: false
175+
- uses: oven-sh/setup-bun@v2
176+
- uses: actions/setup-node@v7
177+
with:
178+
node-version: '24.x'
179+
- run: bun install --frozen-lockfile
180+
- name: Install the pinned real compiler
181+
shell: bash
182+
env:
183+
COMPILER_PACKAGE: ${{ matrix.package }}
184+
COMPILER_DIRECTORY: ${{ matrix.directory }}
185+
COMPILER_EXECUTABLE: ${{ matrix.executable }}
186+
EXPECTED_HBC: ${{ matrix.hbc }}
187+
run: |
188+
set -euo pipefail
189+
root="$RUNNER_TEMP/hermes-tests/$COMPILER_DIRECTORY"
190+
mkdir -p "$root"
191+
archive=$(npm pack "$COMPILER_PACKAGE" --pack-destination "$RUNNER_TEMP" --silent)
192+
tar -xzf "$RUNNER_TEMP/$archive" --strip-components=1 -C "$root" \
193+
"package/$COMPILER_EXECUTABLE" package/package.json
194+
export HERMESC="$root/$COMPILER_EXECUTABLE"
195+
test -x "$HERMESC"
196+
"$HERMESC" -version
197+
bun -e 'import {probeHbcVersion} from "./src/utils/hermes-base"; if (probeHbcVersion(process.env.HERMESC) !== Number(process.env.EXPECTED_HBC)) throw new Error("unexpected HBC version");'
198+
echo "HERMESC=$HERMESC" >> "$GITHUB_ENV"
199+
- name: Fetch pinned real Metro fixtures
200+
shell: bash
201+
run: |
202+
set -euo pipefail
203+
root="$RUNNER_TEMP/hermes-metro-fixtures"
204+
mkdir -p "$root"
205+
base="https://raw.githubusercontent.com/sunnylqm/hbc-diff-benchmark/e6a870a1c26c4b64c7860d7e1aa575707d22ad88"
206+
for file in base.jsbundle s3-medium-feature.jsbundle; do
207+
curl --fail --location --retry 2 --max-time 60 "$base/fixtures/$file" -o "$root/$file"
208+
done
209+
curl --fail --location --retry 2 --max-time 60 "$base/LICENSE" -o "$root/LICENSE"
210+
echo "11c8ad8f7e8c7c59ee45582c77d896a35fa646617f3ba0f5b338a425a7c93b7d $root/base.jsbundle" | sha256sum --check
211+
echo "a693e68254b6c13fae8f839d20c14f9d11c5ab98d4be1b8d13ba1e929a12d752 $root/s3-medium-feature.jsbundle" | sha256sum --check
212+
echo "HERMES_METRO_FIXTURES=$root" >> "$GITHUB_ENV"
213+
- name: Run real compiler and fallback regressions
214+
run: bun test tests/hermes-*.test.ts
215+
- name: Run seeded differential fuzzing
216+
run: bun run fuzz:hermes-base --rounds 50 --seed ${{ matrix.hbc }} --out "${{ runner.temp }}/hermes-fuzz"
217+
- name: Preserve failing fuzz cases
218+
if: failure()
219+
uses: actions/upload-artifact@v7
220+
with:
221+
name: hermes-fuzz-hbc-${{ matrix.hbc }}${{ matrix.suffix }}
222+
path: ${{ runner.temp }}/hermes-fuzz
223+
if-no-files-found: ignore

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ const publishResult = await provider.publish({
8787

8888
Hermes projects: `bundle` always runs hermesc with `-output-source-map`, so the debug info section is stripped from the bytecode (15–40% smaller, same as React Native's own release builds). The Hermes sourcemap stays in the intermediate directory (`.pushy/intermedia/<platform>/<bundle>.map`, never packed into the ppk) and is composed with the packager map — `--sourcemap` is on by default since 2.23 (`--no-sourcemap` opts out). When `bundle` publishes, that final map is uploaded and archived with the version (`sourceMapKey`), so `pushy symbolicate` can map crash stacks — including Hermes `address at` frames — back to source later. `pushy publish <ppk> --sourcemap <file.map>` archives a map for a ppk built elsewhere; publishing without a map prints a warning.
8989

90-
Hermes delta mode (`-base-bytecode`): by default (`--hermesBase auto`) `bundle` compiles against the previous HBC of the same app, which keeps Hermes string IDs stable and makes hot-update patches 5–30× smaller. The base comes from the server (`GET /app/:id/hermesBase`), verified by sha256 and kept in a local cache (`.pushy/cache/<sha256>`, 500 MB / 20 files, `PUSHY_CACHE_DIR` / `--cacheMaxMb` to tune, `pushy cache [clean]` to inspect or clear). `--hermesBase none` disables it; `--hermesBase <file.hbc|.ppk|.apk|.ipa>` uses a local artifact (for example the store build). `--verifyHermesBase` (default on) additionally compiles without the base (concurrently with the base compile) and compares both disassemblies — literal buffers by content, then function by function, with only representation differences (string ids, operand widths, jump distances, buffer offsets) folded away; on any mismatch or failure the CLI falls back to the plain compile, so the feature can never block a release. The log names the first difference (function, line, both sides) or, separately, a dump that could not be read; set `PUSHY_HERMES_BASE_DEBUG=1` to keep both disassemblies (`hermes-base-dump-base.txt` / `hermes-base-dump-plain.txt` next to the intermediate directory) for a bug report. What the check covers, what it does not yet, and how to triage a rejection: [docs/hermes-base-verification.md](docs/hermes-base-verification.md). The result is reported at publish (`hermesBaseOutcome`: `used` / `rejected` / `dump-failed` / `none`, plus the first difference) so the server can watch the rejection rate across apps. `HERMESC=<path> bun run fuzz:hermes-base --rounds 300` compiles random programs against random bases and reports any build the check would wrongly reject (differential fuzzing of the normalization rules). Only hermesc builds that include the upstream delta-mode fix are used (classic `react-native/sdks/hermesc`, or `hermes-compiler` ≥ 250829098). If a base compile fails, the full hermesc output is written to `hermes-base-error.log` next to the intermediate directory. `--resetCache false` skips Metro's `--reset-cache` and reuses its transform cache, which makes repeated bundles much faster.
90+
Hermes delta mode (`-base-bytecode`): by default (`--hermesBase auto`) `bundle` compiles against the previous HBC of the same app, which keeps Hermes string IDs stable and makes hot-update patches 5–30× smaller. The base comes from the server (`GET /app/:id/hermesBase`), verified by sha256 and kept in a local cache (`.pushy/cache/<sha256>`, 500 MB / 20 files, `PUSHY_CACHE_DIR` / `--cacheMaxMb` to tune, `pushy cache [clean]` to inspect or clear). `--hermesBase none` disables it; `--hermesBase <file.hbc|.ppk|.apk|.ipa>` uses a local artifact (for example the store build). `--verifyHermesBase` (default on) additionally compiles without the base (concurrently with the base compile) and checks both artifacts in two passes: readable disassembly followed by raw operands resolved against complete binary strings, constants, function references and control-flow targets. Quoted whitespace and branch destinations are preserved; unsupported or unreadable layouts fail closed. On a mismatch, verification failure or compiler timeout the CLI falls back to the plain compile; failures of the plain compiler or the final sourcemap still fail the build. The raw pass adds verification time and holds both HBC files in memory, but does not add another compile. The log names the first difference (function, line, both sides) or, separately, a dump that could not be read; set `PUSHY_HERMES_BASE_DEBUG=1` to keep both disassemblies (`hermes-base-dump-base.txt` / `hermes-base-dump-plain.txt` next to the intermediate directory) for a bug report. Probe, verification and compile/sourcemap process deadlines default to 30/120/300 seconds respectively, configurable in milliseconds via `PUSHY_HERMES_PROBE_TIMEOUT_MS`, `PUSHY_HERMES_VERIFY_TIMEOUT_MS` and `PUSHY_HERMES_COMPILE_TIMEOUT_MS`. What the check covers, what it does not yet, and how to triage a rejection: [docs/hermes-base-verification.md](docs/hermes-base-verification.md). The result is reported at publish (`hermesBaseOutcome`: `used` / `rejected` / `dump-failed` / `none`, plus the first difference) so the server can watch the rejection rate across apps. `HERMESC=<path> bun run fuzz:hermes-base --rounds 300` compiles random programs against random bases and reports any build the check would wrongly reject (differential fuzzing of the normalization rules). Only hermesc builds that include the upstream delta-mode fix are used (classic `react-native/sdks/hermesc`, or `hermes-compiler` ≥ 250829098). If a base compile fails, the full hermesc output is written to `hermes-base-error.log` next to the intermediate directory. `--resetCache false` skips Metro's `--reset-cache` and reuses its transform cache, which makes repeated bundles much faster.
9191

9292
### Version
9393

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ const publishResult = await provider.publish({
7878

7979
Hermes 工程:`bundle` 调用 hermesc 时始终带 `-output-source-map`,因此字节码不含 debug info 段(小 15%~40%,与 React Native 自身 release 构建一致)。Hermes sourcemap 保留在中间目录(`.pushy/intermedia/<platform>/<bundle>.map`,不会打进 ppk),并与 packager map 合成——自 2.23 起 `--sourcemap` 默认开启(`--no-sourcemap` 关闭)。`bundle` 发布时会把这份最终 map 上传并随版本归档(`sourceMapKey`),之后用 `pushy symbolicate` 即可把崩溃堆栈(含 Hermes 的 `address at` 帧)还原到源码。别处打好的 ppk 可用 `pushy publish <ppk> --sourcemap <file.map>` 归档;不带 map 发布会打印警告。
8080

81-
Hermes delta 模式(`-base-bytecode`):默认 `--hermesBase auto`,`bundle` 会以同一应用上一版的 HBC 为 base 编译,让 Hermes 字符串 ID 跨版本稳定,热更 patch 可缩小 5~30 倍。base 由服务端(`GET /app/:id/hermesBase`)给出、按 sha256 校验并存入本地缓存(`.pushy/cache/<sha256>`,默认 500 MB / 20 个,可用 `PUSHY_CACHE_DIR` / `--cacheMaxMb` 调整,`pushy cache [clean]` 查看或清空)。`--hermesBase none` 关闭;`--hermesBase <file.hbc|.ppk|.apk|.ipa>` 指定本地文件(比如商店包)作 base。`--verifyHermesBase`(默认开)会并行再做一次普通编译并比对两份反汇编——先按内容比对字面量缓冲区,再逐函数比对,只折叠纯表示层差异(字符串 id、操作数宽度、跳转距离、缓冲区偏移);任何不一致或失败都回退到普通编译,不会阻塞发版。日志会给出第一处差异(函数、行、两侧内容),dump 读取失败会单独说明;设置 `PUSHY_HERMES_BASE_DEBUG=1` 可把两份反汇编保留在中间目录旁(`hermes-base-dump-base.txt` / `hermes-base-dump-plain.txt`)用于提 issue。校验覆盖什么、还缺什么、如何排查一次拒绝:见 [docs/hermes-base-verification.md](docs/hermes-base-verification.md)。校验结果会随发布上报(`hermesBaseOutcome`:`used` / `rejected` / `dump-failed` / `none`,附第一处差异),服务端可据此观察全体应用的拒绝率。`HERMESC=<path> bun run fuzz:hermes-base --rounds 300` 会用随机程序配随机 base 编译并报告校验误杀的构建(对归一化规则做差分模糊测试)。只有包含上游 delta 模式修复的 hermesc 才会启用(经典 `react-native/sdks/hermesc`,或 `hermes-compiler` ≥ 250829098)。base 编译失败时,完整的 hermesc 输出会写到中间目录旁边的 `hermes-base-error.log`。`--resetCache false` 可跳过 Metro 的 `--reset-cache`,复用其转换缓存,重复打包会快很多。
81+
Hermes delta 模式(`-base-bytecode`):默认 `--hermesBase auto`,`bundle` 会以同一应用上一版的 HBC 为 base 编译,让 Hermes 字符串 ID 跨版本稳定,热更 patch 可缩小 5~30 倍。base 由服务端(`GET /app/:id/hermesBase`)给出、按 sha256 校验并存入本地缓存(`.pushy/cache/<sha256>`,默认 500 MB / 20 个,可用 `PUSHY_CACHE_DIR` / `--cacheMaxMb` 调整,`pushy cache [clean]` 查看或清空)。`--hermesBase none` 关闭;`--hermesBase <file.hbc|.ppk|.apk|.ipa>` 指定本地文件(比如商店包)作 base。`--verifyHermesBase`(默认开)会并行再做一次普通编译并进行两遍校验:先比较易读反汇编,再将 raw 操作数与二进制中的完整字符串、常量、函数引用和控制流目标核对。字符串内部空白和分支目的地不会被抹掉;不支持或无法解析的布局不能判为等价。发现差异、无法完成校验或 base 编译超时会回退到普通编译;真正的 plain 编译或最终 sourcemap 失败仍会使构建失败。raw 核对增加验证时间并在内存中持有两份 HBC,但不增加编译次数。日志会给出第一处差异(函数、行、两侧内容),dump 读取失败会单独说明;设置 `PUSHY_HERMES_BASE_DEBUG=1` 可把两份反汇编保留在中间目录旁(`hermes-base-dump-base.txt` / `hermes-base-dump-plain.txt`)用于提 issue。版本探测、完整验证、单个编译/sourcemap 子进程的期限默认分别为 30/120/300 秒,可用 `PUSHY_HERMES_PROBE_TIMEOUT_MS`、`PUSHY_HERMES_VERIFY_TIMEOUT_MS`、`PUSHY_HERMES_COMPILE_TIMEOUT_MS`(毫秒)调整。校验覆盖什么、还缺什么、如何排查一次拒绝:见 [docs/hermes-base-verification.md](docs/hermes-base-verification.md)。校验结果会随发布上报(`hermesBaseOutcome`:`used` / `rejected` / `dump-failed` / `none`,附第一处差异),服务端可据此观察全体应用的拒绝率。`HERMESC=<path> bun run fuzz:hermes-base --rounds 300` 会用随机程序配随机 base 编译并报告校验误杀的构建(对归一化规则做差分模糊测试)。只有包含上游 delta 模式修复的 hermesc 才会启用(经典 `react-native/sdks/hermesc`,或 `hermes-compiler` ≥ 250829098)。base 编译失败时,完整的 hermesc 输出会写到中间目录旁边的 `hermes-base-error.log`。`--resetCache false` 可跳过 Metro 的 `--reset-cache`,复用其转换缓存,重复打包会快很多。
8282

8383
### Version
8484

0 commit comments

Comments
 (0)