Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/control-byte-gate-scans-all-c0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
---

chore(scripts): `check:nul-bytes` 扫描面从 NUL 扩到整个 C0 控制字符集,并清掉仓内既存的 6 枚裸控制字节 (#5157)

这道门禁 #4890 落地时只扫 `0x00`,理由是它的报错文案只论证得了 NUL 的后果(grep/ripgrep 把整个文件当二进制、静默返回零匹配)。这个理由实测确实是 NUL 专属的:GNU grep 3.11 与 ripgrep 14.1 对含 `0x00` 的文件报 "binary file matches",对含 `0x01`/`0x03` 的文件照常匹配。所以本次扩面**不是**把 NUL 的论证外推,而是另一条独立的、落在整个 C0 集上的危害:

- **这些字节在任何人类阅读的地方都渲染成"什么都没有"**。本次从仓里清掉的两个真实样本,读起来都是空串,而它们是承重的:`const key = keyParts.join('<0x01>')` 在 grep 输出、diff、code review 里都显示成 `keyParts.join('')` —— 一个"显然多余、下一个读者会顺手删掉"的调用;`return \`${object}<0x01>${recordId}<0x01>${field}\`` 显示成三段直接拼接,即"没有分隔符的复合键"这一经典碰撞 bug 的样子,读者会去"修"一个并不存在的缺陷。**对每一个读者说谎的代码,比 grep 找不到的代码更糟**,因为没有任何信号提示还存在第二种读法。
- **两种拼写都搜不到**。本意写 `\u0001` 的作者,既不能 grep `\u0001`(文件里是字节,不是这段文本),也没法把那个字节敲进搜索框。
- **事故源不挑字节**。本仓每一例都来自"作者正在写关于这个字节的内容时,编辑工具把转义落成了真字节":#4763(派发文)、#4890(写「不要写裸 NUL」这条规则的过程中,一个裸 NUL 落进 SKILL.md)、PR #5140 —— 也正是催生本单的那次:被门禁抓到的 NUL 修好了,14 字节外的一个 `0x01` 从 NUL-only 的修复下溜走。

扫描面现在是 `[\x00-\x08\x0b\x0c\x0e-\x1f]`(C0 全集,排除 tab/LF/CR),与 #4890 议题里当年那次手工扫描的模式一致 —— 门禁当初正是在这一步收窄成了 NUL-only。

**二进制判据同步扩面,而且这一步是承重的而非顺手对齐。** 原判据是"剔除 NUL 后整文件 UTF-8 严格解码,解不通才算二进制";现在剔除的是整个扫描集。原因是控制字节**能**打断一个本来合法的多字节序列:`E4 B8 01 AD` 是「中」被塞进一个 `0x01`,只剔 NUL 的话它解码失败 → 文件被判二进制 → 跳过 → **那个 `0x01` 成了自己的不在场证明**,正是本门禁要打破的那个循环(git 对 NUL 掉进去的那个),只是换了一个字节值。反方向不会出错:扫描集全部 `<= 0x1f`,而合法 UTF-8 多字节序列只由 `>= 0x80` 的字节构成,所以剔除它们永远不会破坏一个本来合法的序列。实测全部 5448 个受追踪路径:扩面后文本/二进制的判定**零变化**(仍是 4 个 PNG + 1 个 ICO 跳过),即这条反循环性质是白拿的。

**仓内既存的 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 枚)。转义后的字符串在运行时**逐字节相同**,行为不变,因此不发版。

脚本名与 `pnpm check:nul-bytes` 命令名保持不变:这两个字符串被 CI、其他门禁的注释、以及若干 agent 指令文件引用,其中一部分正被其他在飞工作占用;改名换来的是名字更准,代价是一次跨文件、只改了一半的重命名。语义变化写在脚本头、报错文案和 CI 步骤三处。`--self-test` 断言数 16 → 34,新增断言把"改前绿/改后红"钉在代码旁边(每个新样本文件**整个文件都不含 NUL**,所以旧的 `buf.indexOf(0)` 扫描确实无事可做);把扩面回退成 NUL-only,自测立刻 8 条失败。工具链改动,不发版。
27 changes: 19 additions & 8 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,25 @@ jobs:
- name: Slot-lookup ratchet
run: pnpm check:slot-lookup

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

# Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async function promptPassword(promptText: string): Promise<string> {

const handler = (char: string) => {
switch (char) {
case '': // Ctrl+C
case '\u0003': // Ctrl+C
cleanup();
process.kill(process.pid, 'SIGINT');
break;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async function promptPassword(promptText: string): Promise<string> {

const handler = (char: string) => {
switch (char) {
case '': // Ctrl+C
case '\u0003': // Ctrl+C
cleanup();
process.kill(process.pid, 'SIGINT');
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function rebucketCrossObject(
resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET;
}

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

let bucket = buckets.get(key);
if (!bucket) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export interface VerifyReferencesOptions {
}

function slotKey(object: string, recordId: string, field: string): string {
return `${object}${recordId}${field}`;
return `${object}\u0001${recordId}\u0001${field}`;
}

function fileFieldsOf(engine: VerifyReferencesEngine, objectName: string): string[] {
Expand Down
Loading
Loading