Skip to content

Commit aacaa01

Browse files
committed
chore(scripts): the raw-byte gate scans the whole C0 control set, not only NUL (#5157)
`check:nul-bytes` shipped from #4890 scanning 0x00 alone, because 0x00 was the byte whose harm its own failure message could prove: a raw NUL makes grep and ripgrep classify the whole file as binary and silently return zero matches. Measured, that argument really is NUL-specific — GNU grep 3.11 and ripgrep 14.1 report "binary file matches" for a file carrying 0x00 and keep matching normally for one carrying 0x01 or 0x03 — so this change is not that argument extended by assertion. It is a second harm that lands on the whole C0 set. The other C0 controls render as NOTHING wherever a human reads the code. Both specimens this commit removed from the tree read as an empty string while being load-bearing: const key = keyParts.join('<0x01>'); // shows as: keyParts.join('') return `${object}<0x01>${recordId}`; // shows as: plain concatenation grep prints the match, the diff prints the line, and review sees `join('')` — an obviously pointless call a later reader is invited to delete, or a separator-less composite key a later reader is invited to "fix". Code that lies to every reader is worse than code grep cannot find, because nothing signals a second reading exists. Nor can the author search for it: not the escape text (the file holds a byte) and not the byte (nobody can type it). And the accident source does not pick byte values — every occurrence in this repo came from an editing tool materialising an escape while someone wrote ABOUT the byte (#4763, #4890, and PR #5140, where the caught NUL was fixed and a 0x01 fourteen bytes away was not). Scanned set is now `[\x00-\x08\x0b\x0c\x0e-\x1f]`: the C0 range minus tab, LF and CR — the pattern #4890's own manual sweep used before the gate narrowed to NUL. The binary probe widens with it, and that step is load-bearing rather than cosmetic tidiness. A control byte CAN break an otherwise-valid multi-byte sequence: `E4 B8 01 AD` is 中 with a 0x01 spliced into it, and stripping only NUL leaves that undecodable, so the file is skipped as binary and the 0x01 becomes its own alibi — the exact circularity this gate exists to break, one byte value over. Widening cannot err the other way: every scanned byte is <= 0x1f while valid UTF-8 multi-byte sequences are built only from bytes >= 0x80. Measured over all 5448 tracked paths, the widened stripping moves zero files between text and binary. Six raw control bytes already in the tree are escaped here; the NUL-only gate was green over all of them. `login.ts` / `register.ts` carried a 0x03 Ctrl+C case label, `cross-object-rebucket.ts` a 0x01 bucket-key separator (in the comment and in the `join`), `verify-file-references.ts` two 0x01 in `slotKey`. Escaped strings are byte-identical at runtime, so no behaviour changes and nothing ships. The script and its `pnpm check:nul-bytes` command keep their historical names: those strings are referenced from CI, from other gates' comments and from agent instruction files, several owned by other in-flight work, and a rename buys a more accurate name at the price of a half-applied one. The widened semantics are stated in the script header, the failure message and the CI step instead. `--self-test` goes 16 -> 34 assertions. The new ones pin the widening in both directions: every new specimen file contains no NUL anywhere, so the pre-#5157 `buf.indexOf(0)` scan had nothing to find, and reverting the widening turns the self-test red with 8 failures rather than leaving it quietly green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE
1 parent 01c0bae commit aacaa01

7 files changed

Lines changed: 316 additions & 73 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
---
3+
4+
chore(scripts): `check:nul-bytes` 扫描面从 NUL 扩到整个 C0 控制字符集,并清掉仓内既存的 6 枚裸控制字节 (#5157)
5+
6+
这道门禁 #4890 落地时只扫 `0x00`,理由是它的报错文案只论证得了 NUL 的后果(grep/ripgrep 把整个文件当二进制、静默返回零匹配)。这个理由实测确实是 NUL 专属的:GNU grep 3.11 与 ripgrep 14.1 对含 `0x00` 的文件报 "binary file matches",对含 `0x01`/`0x03` 的文件照常匹配。所以本次扩面**不是**把 NUL 的论证外推,而是另一条独立的、落在整个 C0 集上的危害:
7+
8+
- **这些字节在任何人类阅读的地方都渲染成"什么都没有"**。本次从仓里清掉的两个真实样本,读起来都是空串,而它们是承重的:`const key = keyParts.join('<0x01>')` 在 grep 输出、diff、code review 里都显示成 `keyParts.join('')` —— 一个"显然多余、下一个读者会顺手删掉"的调用;`return \`${object}<0x01>${recordId}<0x01>${field}\`` 显示成三段直接拼接,即"没有分隔符的复合键"这一经典碰撞 bug 的样子,读者会去"修"一个并不存在的缺陷。**对每一个读者说谎的代码,比 grep 找不到的代码更糟**,因为没有任何信号提示还存在第二种读法。
9+
- **两种拼写都搜不到**。本意写 `\u0001` 的作者,既不能 grep `\u0001`(文件里是字节,不是这段文本),也没法把那个字节敲进搜索框。
10+
- **事故源不挑字节**。本仓每一例都来自"作者正在写关于这个字节的内容时,编辑工具把转义落成了真字节":#4763(派发文)、#4890(写「不要写裸 NUL」这条规则的过程中,一个裸 NUL 落进 SKILL.md)、PR #5140 —— 也正是催生本单的那次:被门禁抓到的 NUL 修好了,14 字节外的一个 `0x01` 从 NUL-only 的修复下溜走。
11+
12+
扫描面现在是 `[\x00-\x08\x0b\x0c\x0e-\x1f]`(C0 全集,排除 tab/LF/CR),与 #4890 议题里当年那次手工扫描的模式一致 —— 门禁当初正是在这一步收窄成了 NUL-only。
13+
14+
**二进制判据同步扩面,而且这一步是承重的而非顺手对齐。** 原判据是"剔除 NUL 后整文件 UTF-8 严格解码,解不通才算二进制";现在剔除的是整个扫描集。原因是控制字节****打断一个本来合法的多字节序列:`E4 B8 01 AD` 是「中」被塞进一个 `0x01`,只剔 NUL 的话它解码失败 → 文件被判二进制 → 跳过 → **那个 `0x01` 成了自己的不在场证明**,正是本门禁要打破的那个循环(git 对 NUL 掉进去的那个),只是换了一个字节值。反方向不会出错:扫描集全部 `<= 0x1f`,而合法 UTF-8 多字节序列只由 `>= 0x80` 的字节构成,所以剔除它们永远不会破坏一个本来合法的序列。实测全部 5448 个受追踪路径:扩面后文本/二进制的判定**零变化**(仍是 4 个 PNG + 1 个 ICO 跳过),即这条反循环性质是白拿的。
15+
16+
**仓内既存的 6 枚裸控制字节一并转义**(改前 NUL-only 门禁全绿,改后判红 4 个文件):`packages/cli/src/commands/login.ts``register.ts``case '<0x03>': // Ctrl+C`(各 1 枚),`packages/services/service-analytics/.../cross-object-rebucket.ts` 的分桶键分隔符(注释 + `join()` 各 1 枚),`packages/services/service-storage/src/verify-file-references.ts``slotKey()`(2 枚)。转义后的字符串在运行时**逐字节相同**,行为不变,因此不发版。
17+
18+
脚本名与 `pnpm check:nul-bytes` 命令名保持不变:这两个字符串被 CI、其他门禁的注释、以及若干 agent 指令文件引用,其中一部分正被其他在飞工作占用;改名换来的是名字更准,代价是一次跨文件、只改了一半的重命名。语义变化写在脚本头、报错文案和 CI 步骤三处。`--self-test` 断言数 16 → 34,新增断言把"改前绿/改后红"钉在代码旁边(每个新样本文件**整个文件都不含 NUL**,所以旧的 `buf.indexOf(0)` 扫描确实无事可做);把扩面回退成 NUL-only,自测立刻 8 条失败。工具链改动,不发版。

.github/workflows/lint.yml

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,25 @@ jobs:
7676
- name: Slot-lookup ratchet
7777
run: pnpm check:slot-lookup
7878

79-
# Raw NUL guard (#3127): one literal U+0000 byte makes grep/ripgrep treat
80-
# the whole file as binary and silently return ZERO matches — the file drops
81-
# out of code search and out of every grep-based lint, with no error saying
82-
# so. Nothing else catches it: git sniffs only the first 8000 bytes to decide
83-
# binary-ness, and protocol.ts carried its NUL at offset 147230, so it kept
84-
# diffing as ordinary text through review. That blind spot let six files
85-
# accumulate the same defect. Authors must write the unicode escape instead.
86-
- name: Raw NUL byte guard
79+
# Raw control-byte guard (#3127 / #4890 / #5157). Scans every tracked TEXT
80+
# file for a raw C0 control byte — 0x00-0x08, 0x0b, 0x0c, 0x0e-0x1f, i.e.
81+
# everything except tab/LF/CR. Two distinct harms, one gate:
82+
# • A literal U+0000 makes grep/ripgrep treat the whole file as binary and
83+
# silently return ZERO matches — the file drops out of code search and
84+
# out of every grep-based lint, with no error saying so. Nothing else
85+
# catches it: git sniffs only the first 8000 bytes to decide binary-ness,
86+
# and protocol.ts carried its NUL at offset 147230, so it kept diffing as
87+
# ordinary text through review. That blind spot let six files accumulate
88+
# the same defect.
89+
# • The other C0 controls still match in grep but RENDER AS NOTHING, so a
90+
# load-bearing separator reads as an empty string in the diff and in
91+
# review: `keyParts.join('<0x01>')` shows up as `keyParts.join('')`.
92+
# Four tracked source files carried those past the NUL-only gate until
93+
# #5157 widened the scan surface; PR #5140 is the case that found it,
94+
# when a 0x01 sitting 14 bytes from a caught NUL went unfixed.
95+
# The command name stays `check:nul-bytes` for continuity — see the script's
96+
# header for why. Authors must write the unicode escape instead of the byte.
97+
- name: Raw control-byte guard
8798
run: pnpm check:nul-bytes
8899

89100
# Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in

packages/cli/src/commands/login.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async function promptPassword(promptText: string): Promise<string> {
3535

3636
const handler = (char: string) => {
3737
switch (char) {
38-
case '': // Ctrl+C
38+
case '\u0003': // Ctrl+C
3939
cleanup();
4040
process.kill(process.pid, 'SIGINT');
4141
break;

packages/cli/src/commands/register.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async function promptPassword(promptText: string): Promise<string> {
3131

3232
const handler = (char: string) => {
3333
switch (char) {
34-
case '': // Ctrl+C
34+
case '\u0003': // Ctrl+C
3535
cleanup();
3636
process.kill(process.pid, 'SIGINT');
3737
break;

packages/services/service-analytics/src/strategies/cross-object-rebucket.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function rebucketCrossObject(
118118
resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET;
119119
}
120120

121-
// Bucket key = base dims (unchanged) + resolved attributes. `` is a
121+
// Bucket key = base dims (unchanged) + resolved attributes. `\u0001` is a
122122
// separator no group value contains, matching the engine's own convention.
123123
const keyParts: string[] = [];
124124
// JSON-encoded, so the empty bucket (`null` on both aggregation paths since
@@ -128,7 +128,7 @@ export function rebucketCrossObject(
128128
// bucket keeps the row's own value verbatim below.
129129
for (const f of baseDimFields) keyParts.push(`${f}=${JSON.stringify(row[f] ?? null)}`);
130130
for (const cd of crossDims) keyParts.push(`${cd.outputName}=${String(resolved[cd.outputName])}`);
131-
const key = keyParts.join('');
131+
const key = keyParts.join('\u0001');
132132

133133
let bucket = buckets.get(key);
134134
if (!bucket) {

packages/services/service-storage/src/verify-file-references.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export interface VerifyReferencesOptions {
104104
}
105105

106106
function slotKey(object: string, recordId: string, field: string): string {
107-
return `${object}${recordId}${field}`;
107+
return `${object}\u0001${recordId}\u0001${field}`;
108108
}
109109

110110
function fileFieldsOf(engine: VerifyReferencesEngine, objectName: string): string[] {

0 commit comments

Comments
 (0)