Skip to content

Commit 1376c5f

Browse files
os-zhuangclaude
andauthored
ci(lint): reject raw NUL bytes in tracked sources (#3135)
A single literal U+0000 byte makes grep/ripgrep classify the whole file as binary and silently return zero matches: `grep -n saveMetaItem packages/metadata-protocol/src/protocol.ts` reported nothing despite 16 real hits, hiding a core protocol file from code search and from every grep-based lint -- with no error to say so. Nothing caught this. git decides binary-ness from the first 8000 bytes only, and protocol.ts carried its NUL at offset 147230, so it kept diffing as ordinary text through review. That blind spot is how six separate files accumulated the same defect before #3127 fixed them. Add scripts/check-nul-bytes.mjs, wired into the lint workflow beside the other guards. It scans tracked .ts/.tsx/.js/.jsx/.mjs/.cjs/.cts/.mts sources (generated and vendored output excluded), reports each offender as file:line:column plus the byte offset -- the location grep cannot give you -- and points the author at the \u0000 escape form and the existing convention at packages/rest/src/rest-server.ts:1065. Verified by replaying the real defect rather than only a synthetic one: restoring protocol.ts to its pre-#3127 state turns the guard red at 3054:72 (offset 147230, confirmed against an independent oracle) while git still diffs that file as ordinary text. The fixed tree is green across 2261 files in ~0.2s. A tracked file under an excluded dir stays ignored even when force-added, so the exclusion is doing real work rather than riding on .gitignore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ce468c8 commit 1376c5f

3 files changed

Lines changed: 120 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ jobs:
5050
- name: ESLint
5151
run: pnpm lint
5252

53+
# Raw NUL guard (#3127): one literal U+0000 byte makes grep/ripgrep treat
54+
# the whole file as binary and silently return ZERO matches — the file drops
55+
# out of code search and out of every grep-based lint, with no error saying
56+
# so. Nothing else catches it: git sniffs only the first 8000 bytes to decide
57+
# binary-ness, and protocol.ts carried its NUL at offset 147230, so it kept
58+
# diffing as ordinary text through review. That blind spot let six files
59+
# accumulate the same defect. Authors must write the unicode escape instead.
60+
- name: Raw NUL byte guard
61+
run: pnpm check:nul-bytes
62+
5363
# Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in
5464
# Markdown/MDX are not type-checked or ESLinted, so skills/ and
5565
# content/docs/ can drift back to teaching the bare `: Page = {}` literal

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"objectui:refresh": "bash scripts/bump-objectui.sh && bash scripts/build-console.sh",
2727
"objectui:clean": "rm -rf packages/console/dist .cache/objectui-*",
2828
"lint": "eslint . --no-inline-config",
29+
"check:nul-bytes": "node scripts/check-nul-bytes.mjs",
2930
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",
3031
"check:role-word": "node scripts/check-role-word.mjs",
3132
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs",

scripts/check-nul-bytes.mjs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/usr/bin/env node
2+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3+
//
4+
// check-nul-bytes -- rejects raw NUL (0x00) bytes in tracked JS/TS sources.
5+
//
6+
// A single raw NUL makes grep/ripgrep classify the WHOLE file as binary and
7+
// silently return zero matches. `grep -n saveMetaItem
8+
// packages/metadata-protocol/src/protocol.ts` reported nothing despite 16 real
9+
// hits -- a core protocol file invisible to code search and to every grep-based
10+
// lint, with no error to say so. The intent in each case was a composite-key
11+
// separator, which must be written as the escape sequence \u0000; that string
12+
// is byte-identical at runtime, so nothing else changes.
13+
//
14+
// Review does not catch this and neither did anything else: git decides
15+
// binary-ness from the first 8000 bytes only, and protocol.ts carried its NUL
16+
// at offset 147230, so it kept diffing as ordinary text. That blind spot is how
17+
// six separate files accumulated the same defect before #3127 fixed them. This
18+
// guard is what keeps them from coming back.
19+
//
20+
// node scripts/check-nul-bytes.mjs
21+
//
22+
// Scope: tracked sources only (git ls-files). Generated and vendored output is
23+
// excluded -- a NUL in a build artifact is that toolchain's business, not ours.
24+
25+
import { execFileSync } from 'node:child_process';
26+
import { readFileSync } from 'node:fs';
27+
import { join } from 'node:path';
28+
29+
// The escape sequence authors should write instead, and the in-repo precedent.
30+
// Written as an escape, never as the byte -- this file is itself in scope, so a
31+
// literal NUL here would make the guard fail on itself.
32+
const ESCAPE = '\\u0000';
33+
const CONVENTION = 'packages/rest/src/rest-server.ts:1065';
34+
35+
// JS/TS source only. This is hand-authored text, where a raw NUL is always a
36+
// mistake -- data fixtures (.json, .snap) can legitimately carry one.
37+
const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.cts', '.mts'];
38+
39+
// Belt-and-braces: git already ignores these, so nothing matches today. Kept so
40+
// a future vendored or committed artifact directory cannot quietly turn this red.
41+
const EXCLUDED = /(^|\/)(node_modules|dist|build|\.next|\.turbo)\//;
42+
43+
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
44+
encoding: 'utf8',
45+
}).trim();
46+
47+
// -z: NUL-delimited output, the one context where the byte is load-bearing
48+
// rather than a bug. Note the escape form -- this file must pass its own check.
49+
const files = execFileSync('git', ['ls-files', '-z'], {
50+
cwd: root,
51+
encoding: 'utf8',
52+
maxBuffer: 64 * 1024 * 1024,
53+
})
54+
.split('\u0000')
55+
.filter(Boolean)
56+
.filter((f) => EXTENSIONS.some((ext) => f.endsWith(ext)))
57+
.filter((f) => !EXCLUDED.test(f));
58+
59+
// Byte offset -> line:column, so the author can jump straight to a byte their
60+
// editor renders as nothing and grep refuses to look for.
61+
function locate(buf, offset) {
62+
let line = 1;
63+
let lineStart = 0;
64+
for (let i = 0; i < offset; i++) {
65+
if (buf[i] === 0x0a) {
66+
line++;
67+
lineStart = i + 1;
68+
}
69+
}
70+
const column = buf.subarray(lineStart, offset).toString('utf8').length + 1;
71+
return { line, column };
72+
}
73+
74+
const offenders = [];
75+
for (const file of files) {
76+
const buf = readFileSync(join(root, file));
77+
const offsets = [];
78+
for (let i = buf.indexOf(0); i !== -1; i = buf.indexOf(0, i + 1)) offsets.push(i);
79+
if (offsets.length === 0) continue;
80+
const { line, column } = locate(buf, offsets[0]);
81+
offenders.push({ file, line, column, offset: offsets[0], count: offsets.length });
82+
}
83+
84+
if (offenders.length === 0) {
85+
console.log(`check-nul-bytes: OK (${files.length} tracked source file(s), no raw NUL bytes).`);
86+
process.exit(0);
87+
}
88+
89+
const plural = offenders.length === 1 ? 'file contains' : 'files contain';
90+
console.error(`check-nul-bytes: ${offenders.length} ${plural} a raw NUL byte (0x00)\n`);
91+
for (const o of offenders) {
92+
const times = o.count === 1 ? '1 occurrence' : `${o.count} occurrences`;
93+
console.error(` • ${o.file}:${o.line}:${o.column} -- ${times}, first at byte offset ${o.offset}`);
94+
}
95+
console.error(`
96+
A raw NUL makes grep/ripgrep treat the entire file as binary and silently return
97+
ZERO matches, so the file drops out of code search and out of every grep-based
98+
lint. git will not warn you: it only scans the first 8000 bytes to decide
99+
binary-ness, so a NUL past that offset keeps diffing as ordinary text.
100+
101+
Write the escape sequence ${ESCAPE} instead of the byte. The resulting string is
102+
byte-identical at runtime, so behaviour does not change. Existing convention --
103+
${CONVENTION}:
104+
105+
const key = environmentId ?? '${ESCAPE}default';
106+
107+
Prefer ${ESCAPE} over \\0, which becomes a legacy octal escape error if it is
108+
ever followed by a digit.`);
109+
process.exit(1);

0 commit comments

Comments
 (0)