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
47 changes: 42 additions & 5 deletions scripts/check-entry-guard.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,7 +62,10 @@
* (`js-comment-mask.mjs`), because a `process.argv[1]` inside a string payload
* for a spawned child is not an entry guard -- `run-with-stall-guard.mjs` really
* does carry one, and an allowlist to excuse it would be a hole the next such
* file falls through silently.
* file falls through silently. One carve-out (#11838): the bytes a `${...}`
* interpolation contributes are SUBTRACTED back out of the mask, because the
* language executes them -- a guard written inside a template literal is still
* a guard. `codeOnly` below states the measurement.
*/

import { readdirSync, readFileSync, statSync } from 'node:fs';
Expand DownExpand Up@@ -107,11 +110,31 @@ function walk(dir, out = []) {
return out;
}

/** Code only: comments, strings, templates and regex literals all blanked. */
/**
* Code only: comments, strings, templates and regex literals blanked — MINUS
* the bytes a `${...}` interpolation contributes, which the language runs.
*
* That subtraction is not a refinement, it is the difference between this gate
* seeing the worst guard in the family and not seeing it. `comment || literal`
* is `js-comment-mask.mjs`'s documented answer and is right for its other
* callers — a `process.argv[1]` inside a string payload for a spawned child is
* not a guard, which `run-with-stall-guard.mjs` relies on — but under it the
* line
*
* import.meta.url === `file://${process.argv[1]}`
*
* is prose. Measured here on 644ad5043 (#11838): 0 findings for that spelling
* against 1 for the plain one, and it is the spelling `invoked-as.mjs`'s header
* singles out as the one that goes inert with no symlink at all. Measured
* downstream (objectui#6092): the straight port of this gate listed 28 of that
* tree's 29 hand-typed guards and omitted exactly the template-written one.
* `interpolation` is the third array added for this; `js-comment-mask.mjs`
* states what it excludes and pins it.
*/
export function codeOnly(source) {
const { comment, literal } = scanSource(source);
const { comment, literal, interpolation } = scanSource(source);
const both = new Uint8Array(comment.length);
for (let i = 0; i < both.length; i++) both[i] = comment[i] || literal[i];
for (let i = 0; i < both.length; i++) both[i] = comment[i] || (literal[i] && !interpolation[i]);
return blank(source, both);
}

Expand DownExpand Up@@ -631,6 +654,20 @@ export function selfTest() {
];
SPELLINGS.forEach((src, i) => t(`spelling ${i + 1} of ${SPELLINGS.length} is rejected`, n(src) > 0, src));

// The percent-encoding spelling lives inside a TEMPLATE, so it is only
// visible through the interpolation-aware view above. Pinned here because
// `codeOnly` reverting to `comment || literal` is a one-character change that
// makes this gate blind to the worst guard in the family while staying green
// (#11838 — measured at 0 findings before the subtraction).
t(
'the percent-encoding guard is seen THROUGH the template it is written in',
n('if (import.meta.url === `file://${process.argv[1]}`) {}') > 0,
);
t(
'...while a process.argv[1] in the template BODY, outside any interpolation, is not a guard',
n('const s = `node x.mjs process.argv[1]`;\n') === 0,
);

// ── the canonical form is accepted ────────────────────────────────────────
t('the canonical guard is accepted', n(`if (${CANONICAL}) { main(); }`) === 0);
t('a file with no guard at all is accepted', n("console.log('hello');\n") === 0);
Expand DownExpand Up@@ -774,7 +811,7 @@ export function selfTest() {
return 1;
}
console.log(
`✓ check-entry-guard self-test: ${cases.length} cases pass — all 11 measured spellings rejected, canonical form and masked prose/payloads accepted, ` +
`✓ check-entry-guard self-test: ${cases.length} cases pass — all 11 measured spellings rejected (the template-interpolation one included), canonical form and masked prose/payloads accepted, ` +
`and the import-safety rule recognised on both sides (dispatch/exit/argv-branch/try rejected; declarations, non-exporters and all three guard spellings accepted) — ` +
`plus the dispatch-gates scan surface, derived from the walked root and held apart from the baseline roster.`,
);
Expand Down
135 changes: 129 additions & 6 deletions scripts/js-comment-mask.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,12 +131,14 @@ const REGEX_AFTER_KEYWORD = new Set([
* regex and comment branches -- and its bytes are flagged literal at the end.
*
* @param {string} source
* @returns {{ comment: Uint8Array, literal: Uint8Array }}
* @returns {{ comment: Uint8Array, literal: Uint8Array, interpolation: Uint8Array }}
*/
export function scanSource(source) {
const n = source.length;
const comment = new Uint8Array(n);
const literal = new Uint8Array(n);
// The code bytes INSIDE `${...}` — see the closing block of this function.
const interpolation = new Uint8Array(n);
let i = 0;
let prev = ''; // last significant CODE character
let word = ''; // ...and the identifier it is the tail of, if any
Expand DownExpand Up@@ -263,7 +265,7 @@ export function scanSource(source) {
if (frame && frame.braces > 0) {
if (c === '{') frame.braces++;
else if (c === '}' && --frame.braces === 0) {
interpolations.push([frame.start, i + 1]);
interpolations.push([frame.start, i + 1, true]);
frame.start = -1;
}
}
Expand All@@ -281,9 +283,68 @@ export function scanSource(source) {
// is only known once its closing brace is found. An unterminated one (EOF
// inside `${`) still gets flagged, so truncated source cannot leak code
// bytes into a caller's "this is not a literal" test.
for (const frame of templates) if (frame.braces > 0 && frame.start >= 0) interpolations.push([frame.start, n]);
for (const frame of templates) if (frame.braces > 0 && frame.start >= 0) interpolations.push([frame.start, n, false]);
// Snapshot BEFORE the blanket flush below: a byte the scan already called a
// literal (a nested template's body, a quoted string inside the expression)
// is content wherever it sits, and must not come back as code.
const wasLiteral = literal.slice();
for (const [start, end] of interpolations) for (let k = start; k < end; k++) literal[k] = 1;
return { comment, literal };

// ── `interpolation`: the third array, and why it is not just `!literal` ──
//
// Ported from objectui (objectui#6092, landed there as PR objectui#6133) for
// #11838. `literal` above is the DOCUMENTED answer and does not move: an
// interpolation's bytes are the enclosing template's literal content, and
// every existing caller keeps exactly the mask it had.
//
// But that answer is wrong for one question, and the question is load-bearing
// enough to have forced the array's existence downstream. A guard written
//
// import.meta.url === `file://${process.argv[1]}`
//
// is the spelling `invoked-as.mjs`'s header singles out as the worst of the
// family — it goes inert with NO SYMLINK AT ALL, percent-encoding apart from
// `argv[1]` in any directory whose name needs encoding. Under
// `comment || literal` those bytes are prose, so a gate scanning for
// hand-typed guards reads that line as a string and reports clean. Measured
// here on 644ad5043 (#11838): 0 findings for the template spelling, 1 for the
// plain one; measured downstream: objectui's first cut of `check-entry-guard`
// listed 28 of that tree's 29 hand-typed guards and silently omitted exactly
// this one. This tree does not write the spelling today, which is precisely
// why the gate has to see it before the first author does.
//
// So this array marks the bytes an interpolation contributes as CODE, which
// a caller can subtract from `literal` to get a view the language would
// execute. It is NOT `!literal`: the `${` and its closing `}` stay masked, so
// a caller counting brackets over the subtracted view stays balanced, and a
// NESTED template's body inside the interpolation stays masked too, because
// those bytes really are content. Both exclusions are pinned in the
// self-test; without them the subtracted view either desyncs a bracket
// counter or hands back string content as code, which is the fabrication
// direction this module's header calls the worse one.
for (const [start, end, terminated] of interpolations) {
// `[start, end)` spans `${` … `}`; the interior is what the language runs.
// An unterminated span (EOF inside `${`) has no closing brace to skip.
const stop = terminated ? end - 1 : end;
for (let k = start + 2; k < stop; k++) if (!wasLiteral[k]) interpolation[k] = 1;
}
// ── the delimiters of EVERY span, including a nested one ────────────────
//
// A second pass, and it is not tidiness. `${a ${b} c}` cannot happen, but
// `${xs.map((v) => `n${v}`)}` does: the INNER `${` is flagged literal inline
// by its own template frame, while the inner `}` is only reached by the
// blanket flush — so the inner `}` looked like interior code of the OUTER
// span and came back as a brace with no opener. Measured while porting to
// objectui: it desynced `check-entry-guard.mjs`'s top-level statement slicer
// badly enough that four files whose dispatch really is guarded were reported
// as running on import. A delimiter is a delimiter no matter whose interior
// it sits in.
for (const [start, end, terminated] of interpolations) {
interpolation[start] = 0;
if (start + 1 < n) interpolation[start + 1] = 0;
if (terminated) interpolation[end - 1] = 0;
}
return { comment, literal, interpolation };
}

/** Replace every flagged character with a space, keeping newlines and offsets. */
Expand DownExpand Up@@ -441,11 +502,73 @@ export function selfTest() {
console.log(` ${problems.length ? '\u2717' : '\u2713'} ${name}${problems.length ? ' -- ' + problems.join('; ') : ''}`);
}

// \u2500\u2500 the `interpolation` array (ported from objectui#6092; #11838) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
//
// Driven directly rather than through the GHOST/REAL corpus above, because
// the property is about a SUBTRACTED view (`literal` minus `interpolation`)
// that no existing caller asks for. Every case states what the view must
// contain and what it must NOT \u2014 a one-sided assertion here would pass on an
// array that flags everything.
const codeView = (src) => {
const { comment, literal, interpolation } = scanSource(src);
const flags = new Uint8Array(src.length);
for (let k = 0; k < flags.length; k++) flags[k] = comment[k] || (literal[k] && !interpolation[k]);
return blank(src, flags);
};
const extra = [];
const x = (name, ok, detail) => extra.push([name, Boolean(ok), detail]);

const guard = 'if (import.meta.url === ' + BT + 'file://${process.argv[1]}' + BT + ') {}';
x('the percent-encoding guard is CODE in the subtracted view', codeView(guard).includes('process.argv[1]'), codeView(guard));
// The documented `comment || literal` view \u2014 what every existing caller
// computes, and the reason the third array had to exist at all.
const documentedView = (src) => {
const { comment, literal } = scanSource(src);
const flags = new Uint8Array(src.length);
for (let k = 0; k < flags.length; k++) flags[k] = comment[k] || literal[k];
return blank(src, flags);
};
x('...and still a LITERAL under the documented comment||literal view, which is unchanged',
!documentedView(guard).includes('process.argv[1]'), documentedView(guard));
x('the template body around it stays masked', !codeView(guard).includes('file://'), codeView(guard));

const delim = 'const s = ' + BT + 'a${ b }c' + BT + ';';
const dv = codeView(delim);
x('the ${ and } delimiters stay masked, so a bracket counter stays balanced',
(dv.match(/[{}]/g) || []).length === 0, dv);
x('...while the expression inside them is code', dv.includes('b'), dv);

const nested = 'const t = ' + BT + '${xs.map((v) => ' + BT + 'x${v}y' + BT + ').join("")}' + BT + ';';
const nv = codeView(nested);
x('a NESTED template body inside an interpolation stays masked', !nv.includes('x') || !/x\$?\{?v/.test(nv), nv);
x('...while the interpolation expression around it is code', nv.includes('xs.map'), nv);

const inner = 'const t = ' + BT + '${xs.map((v) => ' + BT + 'n${v}' + BT + ')}' + BT + ';';
const iv = codeView(inner);
x('a NESTED interpolation contributes NO unbalanced brace to the code view',
(iv.match(/\{/g) || []).length === (iv.match(/\}/g) || []).length, iv);
x('...and the outer interpolation expression is still code', iv.includes('xs.map'), iv);

const quoted = 'const q = ' + BT + '${f("process.argv[1]")}' + BT + ';';
x('a STRING quoted inside an interpolation is not code', !codeView(quoted).includes('process.argv[1]'), codeView(quoted));

const plain = "const s = 'process.argv[1]';";
x('an ordinary string literal is untouched by any of this', !codeView(plain).includes('process.argv[1]'), codeView(plain));

const unterminated = 'const u = ' + BT + '${ g(';
x('an unterminated interpolation does not throw and yields its code', codeView(unterminated).includes('g('), codeView(unterminated));

for (const [name, ok, detail] of extra) {
if (!ok) failed++;
console.log(` ${ok ? '\u2713' : '\u2717'} ${name}${ok ? '' : ' -- ' + JSON.stringify(detail)}`);
}
const total = cases.length + extra.length;

if (failed) {
console.error(`\u2717 js-comment-mask self-test: ${failed} of ${cases.length} case(s) failed.`);
console.error(`\u2717 js-comment-mask self-test: ${failed} of ${total} case(s) failed.`);
process.exit(1);
}
console.log(`\u2713 js-comment-mask self-test: ${cases.length} cases pass.`);
console.log(`\u2713 js-comment-mask self-test: ${total} cases pass (${cases.length} mask/strip corpus, ${extra.length} interpolation view).`);
}

// Executed only as a CLI. Importing this module must have NO side effect: the
Expand Down
Loading