From 71802697ec95dfced87c6eeb243ff27cee3280fa Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Tue, 22 Sep 2026 18:39:33 +0800 Subject: [PATCH] feat(hermes-base): redact and fingerprint the reported detail The outcome column has been collecting rejections that nobody reads, and the one that finally got looked at turned out to carry a customer's property name -- the detail is whatever hermesc printed, which is the user's own code. Both problems are in the report, not in the check. redactFailureDetail replaces what can only come from user code -- quoted string operands, function names, and the paths a compiler's stderr drags in -- with stable tokens that keep each value's length and character class. The shape a fix is reasoned about (opcodes, registers, counts, literal kinds) is untouched, so a rejection is still triageable from the report alone, and the local console keeps the real text, which is where the property name actually helps. failureFingerprint groups the same defect across apps and builds by stripping registers, ids and offsets from the redacted line. It ships as one implementation on purpose: the fuzzer's dedup key is now this same function, so a finding here and the same defect in the field land in one bucket. Two implementations would make the counts fiction. Reporting stays additive -- hermesBaseFingerprint is ignored by servers that do not know it, like hermesBaseOutcome was -- and the detail is redacted exactly once, where the check ran; versions.ts only forwards it. Co-Authored-By: Claude Opus 5 --- docs/hermes-base-verification.md | 2 + scripts/fuzz-hermes-base.ts | 17 ++++--- src/utils/failure-fingerprint.ts | 79 +++++++++++++++++++++++++++++++ src/utils/hermes-base.ts | 23 +++++++-- tests/failure-fingerprint.test.ts | 73 ++++++++++++++++++++++++++++ tests/hermes-base.test.ts | 14 +++++- 6 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 src/utils/failure-fingerprint.ts create mode 100644 tests/failure-fingerprint.test.ts 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({