* test(#2701): failing-first regression for NUL-corrupted plan/state validators * fix(#2701): reject NUL-corrupted plan/state artifacts at the validator entry points * fix(#2701): seed STATE.md in test (writeState); add NUL-path guards to validate/verify for parity (review) * docs(changeset): #2701 validators reject NUL-corrupted artifacts * docs(changeset): backfill #2701 PR number to 2829
This commit is contained in:
@@ -12,6 +12,7 @@ import path from 'node:path';
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output, error } = ioMod;
|
||||
import { platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs';
|
||||
import { textEncodingError } from './validate.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import unusableInputMod = require('./unusable-input.cjs');
|
||||
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
|
||||
@@ -713,11 +714,17 @@ function cmdFrontmatterMerge(cwd: string, filePath: string, data: string | undef
|
||||
|
||||
function cmdFrontmatterValidate(cwd: string, filePath: string, schemaName: string | undefined, raw: boolean): void {
|
||||
if (!filePath || !schemaName) { error('file and schema required'); }
|
||||
if (filePath.includes('\0')) { error('file path contains null bytes'); }
|
||||
const schema = FRONTMATTER_SCHEMAS[schemaName as string];
|
||||
if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); }
|
||||
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
||||
const content = safeReadFile(fullPath);
|
||||
if (!content) { output({ error: 'File not found', path: filePath }, raw, undefined); return; }
|
||||
// #2701: fail loud on NUL/binary corruption before schema checks. A structurally
|
||||
// intact-but-NUL-corrupted file otherwise passes as valid:true and is then silently
|
||||
// skipped by recursive/binary-skipping searchers, reading downstream as "absent."
|
||||
const encErr = textEncodingError(content, filePath);
|
||||
if (encErr) { output({ valid: false, errors: [encErr], schema: schemaName }, raw, 'invalid'); return; }
|
||||
// Pass the resolved path so a truncated file is named in the diagnostic and deduplicated
|
||||
// per file rather than per content digest (#1882, ADR-1411 wiring clause).
|
||||
const fm = extractFrontmatter(content, fullPath);
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { tokenizeHeadings, collectSection, replaceSection } from './markdown-sectionizer.cjs';
|
||||
import type { HeadingToken } from './markdown-sectionizer.cjs';
|
||||
import { parseMarkdownTable, updateTableCell, deleteTableRow, insertTableRow, splitTableRow, isDelimiterRow } from './markdown-table.cjs';
|
||||
import { textEncodingError } from './validate.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2639,6 +2640,14 @@ function cmdStateValidate(cwd: string, raw: boolean): void {
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
// #2701: fail loud on NUL/binary corruption before drift checks. A corrupt
|
||||
// STATE.md otherwise validates as clean and is silently skipped by recursive
|
||||
// searchers downstream, reading as "absent" rather than "corrupt."
|
||||
const encErr = textEncodingError(content, 'STATE.md');
|
||||
if (encErr) {
|
||||
output({ valid: false, warnings: [encErr], drift: {} }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const drift: Record<string, unknown> = {};
|
||||
|
||||
|
||||
@@ -152,3 +152,36 @@ export function buildNotStartedPhaseVariants(roadmapContent: string): Set<string
|
||||
}
|
||||
return notStartedPhases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect binary corruption (embedded NUL bytes) in a text artifact's bytes.
|
||||
*
|
||||
* #2701: the plan/summary/verification/state validators must FAIL LOUD on a
|
||||
* NUL-corrupted file instead of reporting `valid: true`. A NUL byte is the
|
||||
* unambiguous signal — UTF-8 text never contains 0x00 — and a file carrying one
|
||||
* is binary-classified by `file(1)`, then silently OMITTED from recursive /
|
||||
* binary-skipping search results (`rg -l`, `grep -rI`, exit 0), so the corruption
|
||||
* reads downstream as "file absent" rather than "file corrupt." The error message
|
||||
* names that consequence so the next investigator is not misdirected.
|
||||
*
|
||||
* This is a pure, opt-in check called explicitly by each validator at its own
|
||||
* entry point. It is deliberately NOT placed inside the shared `platformReadSync`
|
||||
* read primitive (which dozens of best-effort, tolerant reads flow through and
|
||||
* which must not start hard-failing on encoding). It does NOT strip, sanitize, or
|
||||
* repair the NUL bytes — corruption is a signal of an upstream authoring-tool bug
|
||||
* and must stay visible.
|
||||
*
|
||||
* @param buf the file bytes (Buffer or string; a string is searched char-wise)
|
||||
* @param relPath a path/label for the diagnostic message
|
||||
* @returns an error string when NUL is found, or `null` when the bytes are clean text
|
||||
*/
|
||||
export function textEncodingError(buf: Buffer | string, relPath: string): string | null {
|
||||
const nul = typeof buf === 'string' ? buf.indexOf('\0') : buf.indexOf(0x00);
|
||||
if (nul === -1) return null;
|
||||
return (
|
||||
`${relPath}: file contains NUL bytes (first at offset ${nul}). ` +
|
||||
'Artifact files must be UTF-8 text. A NUL-corrupted file is binary-classified ' +
|
||||
'and silently skipped by recursive / binary-skipping search tools (rg, grep -I), ' +
|
||||
'so downstream verification reports its contents as missing rather than corrupt.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants } from './validate.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
import { phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, canonicalPlanStem } from './validate.cjs';
|
||||
import { phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, canonicalPlanStem, textEncodingError } from './validate.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||||
@@ -811,6 +811,7 @@ function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): vo
|
||||
if (!filePath) {
|
||||
error('file path required');
|
||||
}
|
||||
if (filePath.includes('\0')) { error('file path contains null bytes'); }
|
||||
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
||||
const content = safeReadFile(fullPath);
|
||||
if (!content) {
|
||||
@@ -818,6 +819,15 @@ function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): vo
|
||||
return;
|
||||
}
|
||||
|
||||
// #2701: fail loud on NUL/binary corruption before structure checks. A
|
||||
// structurally intact-but-NUL-corrupted plan otherwise passes as valid and is
|
||||
// silently skipped by recursive/binary-skipping searchers downstream.
|
||||
const encErr = textEncodingError(content, filePath);
|
||||
if (encErr) {
|
||||
output({ valid: false, errors: [encErr] }, raw);
|
||||
return;
|
||||
}
|
||||
|
||||
const fm = extractFrontmatter(content, fullPath);
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user