diff --git a/docs/hermes-base-verification.md b/docs/hermes-base-verification.md index 776a904..a475307 100644 --- a/docs/hermes-base-verification.md +++ b/docs/hermes-base-verification.md @@ -59,6 +59,8 @@ pretty 输出本身会截断长字符串与 BigInt、用函数名替代函数索 CLI(`26764b1`)在 `version/create` 附带 `hermesBaseOutcome: 'used' | 'rejected' | 'dump-failed' | 'none'` 与可选 `hermesBaseDetail`(首处差异或失败原因,≤ 500 个码点)。规则同其它链路字段:只发已知值、绝不发 JSON null、未知就省略字段(单独 `pushy publish` 一个 ppk 时没有校验结果,字段不出现)。outcome 从 `HermesCompileResult.outcome` 带出,与 `base` 分开:base 被拒时 `base` 仍为 null,但 outcome 说明是被拒而不是没找到。base 编译本身失败记为 `none` 并附 `base compile failed: …`。 +上报前 detail 会经 `src/utils/failure-fingerprint.ts` 的 `redactFailureDetail` 脱敏:引号内的字符串操作数、`Function<…>` 的函数名、编译器 stderr 里的路径都换成 `str#/<长度>` / `fn#` / `path#.`,指令形态、寄存器、计数原样保留。本地控制台仍打印未脱敏的原文——属性名在本机排查时才有用;离开这台机器的那份不该带客户代码。同时上报 `hermesBaseFingerprint`(脱敏后再抹掉寄存器号/id/偏移,取 SHA-256 前 16 字节,32 个十六进制字符),同一个缺陷在不同 app、不同寄存器分配下归到同一组。**这个指纹函数只有一份实现**:上报、`scripts/fuzz-hermes-base.ts` 的去重、以后的线上语料回放共用它和同一套测试,否则聚合出来的次数是假的。 + 服务端(pushy-go 分支 `hermes-base-outcome`,提交 `9208c24`)新增可空列 `versions.hermesBaseOutcome` / `hermesBaseDetail`,解析器接受缺字段与 JSON null,只拒绝类型错误与未知枚举值;版本列表接口一并透出。全体应用的拒绝率: ```sql diff --git a/scripts/fuzz-hermes-base.ts b/scripts/fuzz-hermes-base.ts index 801e8c1..d0b9d36 100644 --- a/scripts/fuzz-hermes-base.ts +++ b/scripts/fuzz-hermes-base.ts @@ -27,7 +27,7 @@ import { spawnSync } from 'node:child_process'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; - +import { failureFingerprint } from '../src/utils/failure-fingerprint'; import { compareHermesBytecode } from '../src/utils/hermes-base'; import { fuzzStringLiterals } from './hermes-fuzz-literals'; import { hermesFuzzSucceeded } from './hermes-fuzz-result'; @@ -560,14 +560,13 @@ function compile( return (run.stderr || run.stdout || `exit ${run.status}`).trim(); } -/** collapse ids/offsets/registers so one normalization gap counts once */ -function dedupeKey(detail: string): string { - return detail - .replace(/Function<[^>]*>/g, 'Function<…>') - .replace(/\br\d+\b/g, 'r#') - .replace(/\d+/g, '#') - .replace(/"[^"]*"/g, '"…"'); -} +/** + * Collapse ids/offsets/registers so one normalization gap counts once. This is + * the same key the CLI reports and the server groups by: a finding here and + * the same defect seen in the field have to land in one bucket, which they + * only do while both sides call this one function. + */ +const dedupeKey = failureFingerprint; interface Finding { key: string; diff --git a/src/utils/failure-fingerprint.ts b/src/utils/failure-fingerprint.ts new file mode 100644 index 0000000..78ad809 --- /dev/null +++ b/src/utils/failure-fingerprint.ts @@ -0,0 +1,79 @@ +/** + * One implementation of the failure fingerprint, shared by everything that + * groups the same failure: the version/create report, the fuzzer's dedup of + * its findings, and (later) the replay of the stored corpus. Two + * implementations would mean the counts behind "how often does this happen" + * are fiction, so callers import from here rather than writing their own + * regexes. + * + * Redaction is the other half. A detail line carries whatever hermesc printed, + * which is the user's own code: the first rejection seen in production named + * the property `promotionRequestItemId`. Details travel to the server, into + * issue lists and -- once the fix loop runs -- into public pull requests and CI + * fixtures, so the identifiers are replaced by tokens *before* the report + * leaves the machine. The local console keeps the unredacted text: that is + * where the name is actually useful. + */ +import { createHash } from 'node:crypto'; + +const sha = (value: string) => createHash('sha256').update(value).digest('hex'); + +/** stable stand-in for one redacted value; the same input always yields it */ +const token = (kind: string, value: string) => + `${kind}#${sha(value).slice(0, 8)}`; + +/** + * Replace the parts of a detail line that can only come from the user's code: + * quoted string operands (property names, string literals), function names, + * and filesystem paths that reach the line through a compiler's stderr. What + * stays is the shape a fix is reasoned about -- opcodes, registers, counts, + * literal kinds -- plus each redacted value's length and character class. + * + * This is redaction by class, not a proof: it covers the shapes the comparison + * and the compilers are known to emit. Anything that arrives in an unknown + * shape still has its paths and quoted runs stripped, so a new detail format + * cannot silently start leaking identifiers. + */ +export function redactFailureDetail(detail: string): string { + return ( + detail + // Paths first: a compiler's stderr reaches the line with them, and + // running this pass after the others would eat the `/` suffix + // the string pass writes. + .replace(/(?:\.{0,2}\/)[^\s:,)"']*/g, (path: string) => { + const ext = /\.([A-Za-z0-9]+)$/.exec(path); + return `${token('path', path)}${ext ? `.${ext[1]}` : ''}`; + }) + // Function(…) headers, including the raw-audit variants + .replace( + /\b(Function|NCFunction|Constructor)<([^>]*)>/g, + (_all, kind: string, name: string) => + `${kind}<${name ? token('fn', name) : ''}>`, + ) + // Quoted operands. hermesc does not escape quotes inside strings, so the + // run is taken as-is up to the next quote; a stray tail keeps whatever + // the earlier passes left rather than being reconstructed. + .replace(/"([^"\n]*)"/g, (_all, value: string) => { + const units = Array.from(value); + const ascii = units.every((char) => char.charCodeAt(0) < 0x80); + return `"${token('str', value)}/${units.length}${ascii ? '' : '/u16'}"`; + }) + ); +} + +/** + * The grouping key: a redacted detail with everything that varies between two + * occurrences of the same defect removed -- registers, ids, offsets, labels, + * counts and the redaction tokens themselves. Sixteen bytes; the server stores + * it as 32 hex characters. + */ +export function failureFingerprint(detail: string): string { + const shape = redactFailureDetail(detail) + .replace(/#[0-9a-f]{8}/g, '#') + .replace(/\br\d+\b/g, 'r') + .replace(/\bL\d+\b/g, 'L') + .replace(/\d+/g, 'N') + .replace(/\s+/g, ' ') + .trim(); + return sha(shape).slice(0, 32); +} diff --git a/src/utils/hermes-base.ts b/src/utils/hermes-base.ts index e6bbe4c..e818d4c 100644 --- a/src/utils/hermes-base.ts +++ b/src/utils/hermes-base.ts @@ -18,6 +18,7 @@ import path from 'path'; import { PassThrough, Readable } from 'stream'; import { pipeline } from 'stream/promises'; import { tempDir } from './constants'; +import { failureFingerprint, redactFailureDetail } from './failure-fingerprint'; import { getHbcVersion } from './hbcTransform'; import { normalizeCachedObjectInstruction } from './hermes-cached-object'; import { @@ -95,8 +96,17 @@ export interface HermesBaseMeta { baseHash: string | null; /** absent (never null) when the bundle step did not run hermesc */ hermesBaseOutcome?: HermesBaseOutcome; - /** first difference / failure reason; absent when there is none */ + /** + * First difference / failure reason, redacted (see redactFailureDetail): + * the raw text carries the user's own property and string names. Absent + * when there is none. + */ hermesBaseDetail?: string; + /** + * Grouping key for the same defect across builds and apps, computed from + * the unredacted detail. Absent with the detail. + */ + hermesBaseFingerprint?: string; } // --------------------------------------------------------------------------- @@ -988,8 +998,15 @@ export function hermesBaseMeta( }; if (check) { meta.hermesBaseOutcome = check.outcome; - const detail = truncateHermesBaseDetail(check.detail); - if (detail) meta.hermesBaseDetail = detail; + // The console above keeps the real text -- that is where the property + // name helps. What leaves the machine is redacted and fingerprinted. + const detail = truncateHermesBaseDetail( + redactFailureDetail(check.detail ?? ''), + ); + if (detail) { + meta.hermesBaseDetail = detail; + meta.hermesBaseFingerprint = failureFingerprint(check.detail ?? ''); + } } return meta; } diff --git a/tests/failure-fingerprint.test.ts b/tests/failure-fingerprint.test.ts new file mode 100644 index 0000000..c9ed388 --- /dev/null +++ b/tests/failure-fingerprint.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import { + failureFingerprint, + redactFailureDetail, +} from '../src/utils/failure-fingerprint'; + +// The detail of a rejected Hermes base, in the shape the comparison emits. +const REJECTED = + 'Function(3 params, 21 registers, 1 numbers, 2 non-pointers): +72: ' + + 'DefineOwnById r3, r6, 1, "shipmentTrackingR"... vs ' + + 'DefineOwnById r3, r6, 1, "shipmentTrackingReference"'; + +describe('redactFailureDetail', () => { + test('keeps the shape and drops every name that comes from user code', () => { + const redacted = redactFailureDetail(REJECTED); + expect(redacted).not.toContain('shipmentTracking'); + expect(redacted).not.toContain('Function'); + // what a fix is reasoned about survives + expect(redacted).toContain('DefineOwnById r3, r6, 1,'); + expect(redacted).toContain('+72:'); + // each redacted value keeps its length, so a truncated operand still + // reads as the shorter one + expect(redacted).toContain('/17'); + expect(redacted).toContain('/25'); + }); + + test('marks non-ASCII strings without revealing them', () => { + const redacted = redactFailureDetail( + 'Array Buffer entry 1: [String "中文属性名"] vs [String "bar"]', + ); + expect(redacted).not.toContain('中文'); + expect(redacted).toContain('/5/u16'); + expect(redacted).toContain('/3'); + }); + + test('strips paths that reach the line through a compiler stderr', () => { + const redacted = redactFailureDetail( + 'base dump: exit 3: boom: /Users/someone/app/build/delta.hbc', + ); + expect(redacted).not.toContain('someone'); + expect(redacted).toContain('.hbc'); + expect(redacted).toContain('exit 3'); + }); + + test('the same value always redacts to the same token', () => { + expect(redactFailureDetail(REJECTED)).toBe(redactFailureDetail(REJECTED)); + }); +}); + +describe('failureFingerprint', () => { + test('groups the same defect across apps, registers and ids', () => { + const otherApp = REJECTED.replace(/shipmentTracking/g, 'promotionRequest') + .replace('r3, r6', 'r9, r2') + .replace('+72', '+8'); + expect(failureFingerprint(otherApp)).toBe(failureFingerprint(REJECTED)); + }); + + test('the jump-table offsets of one SwitchImm gap are one group', () => { + const at = (offset: number) => + `Function(4 params, 21 registers, 0 symbols): +5: SwitchImm r0, ${offset}, L4, 3, 31 vs SwitchImm r0, 616, L4, 3, 31`; + expect(failureFingerprint(at(620))).toBe(failureFingerprint(at(618))); + }); + + test('a different instruction is a different group', () => { + expect(failureFingerprint(REJECTED)).not.toBe( + failureFingerprint(REJECTED.replace(/DefineOwnById/g, 'PutByIdLoose')), + ); + }); + + test('is 32 hex characters, as the server column stores it', () => { + expect(failureFingerprint(REJECTED)).toMatch(/^[0-9a-f]{32}$/); + }); +}); diff --git a/tests/hermes-base.test.ts b/tests/hermes-base.test.ts index eff00d1..12d1bc9 100644 --- a/tests/hermes-base.test.ts +++ b/tests/hermes-base.test.ts @@ -612,12 +612,22 @@ describe('helpers', () => { outcome: 'rejected', detail: 'Function line 3:\n a\n b', }); - expect(rejected).toEqual({ + // the function name is redacted on the way out; the shape is not + expect(rejected.hermesBaseDetail).toMatch( + /^Function line 3: a b$/, + ); + expect(rejected.hermesBaseFingerprint).toMatch(/^[0-9a-f]{32}$/); + expect({ + ...rejected, + hermesBaseDetail: '', + hermesBaseFingerprint: '', + }).toEqual({ bytecodeVersion: 98, baseVersionId: null, baseHash: null, hermesBaseOutcome: 'rejected', - hermesBaseDetail: 'Function line 3: a b', + hermesBaseDetail: '', + hermesBaseFingerprint: '', }); // no detail → no key (the server rejects JSON null, and '' is noise) expect(hermesBaseMeta(null, 98, { outcome: 'none' })).toEqual({