mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-24 08:05:39 +00:00
fix(lint): close the round-12 reviewer escape classes (#8084)
Ten Criticals plus five hardenings from the round-12 review, every
class probe-verified before and after:
- R12-1: Worker eval-option analysis now matches runtime object-literal
semantics — the LAST eval key wins (duplicates included), an options
object without eval defaults to false (specifier path, no
over-block), and a spread after the last literal eval is
unverifiable — fail closed.
- R12-2: sequence unwrapping is now a uniform invariant — recursive on
callees in both visitors and applied to object expressions
(rightmostObjectName/isProcessObject), closing (0, require).call,
(0, (0, require)), (0, process).getBuiltinModule, (0, vm).* and
new (0, vm).Script.
- R12-3: call/apply/bind indirection is complete — Function/constructor
forward code (unconditional fail-closed), chained indirection
(x.call.call) fails closed instead of falling through, and vm exec
names plus the fork/vitest alias sets resolve.
- R12-4: Reflect.apply/construct target lists mirror the direct-call
arms — Function (incl. member spellings), the vm exec/Script surface,
and the vitest loaders (member and identifier targets).
- R12-5: alias sets populate in a pre-pass over the module body — ESM
imports are hoisted, so use-before-import now resolves like the
import-first direction.
- R12-6: renamed destructured vitest imports resolve through a new
vitestLoaderAliases set.
- R12-7: a named guarded global (process/globalThis/global) carrying an
opaque computed key fails closed (process-family keeps the dedicated
moduleBuiltin message); object-agnostic arms keep their documented
residue.
- R12-8: .constructor fails closed on variable bodies and expression
templates; statically non-string literals keep the pass-through.
- R12-9: inline lazy vm imports ((await import('node:vm')).*) count as
vm objects in the exec and Script arms.
- R12-11/12/13: checkSource skips statically non-specifier arguments
(no unactionable advice for env objects), the URL arm owns
new URL(spec, import.meta.url) on every entrance (no over-block, one
report on the serve form), and the module builtin reports
moduleBuiltin on every entrance.
Test hardening: R12-10 normalizes the repoRoot pin to forward slashes
(Windows merge-gate determinism), R12-14 pins the four mutation
survivors, R12-15 pre-cleans and catch-cleans the baseUrl symlink
links. Suite 65/65; guarded trees lint clean.
This commit is contained in:
parent
7112ab4332
commit
552bc7c8f6
2 changed files with 2 additions and 1594 deletions
|
|
@ -1,710 +1 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Keeps the guarded CLI trees (runtime/, utils/,
|
||||
* acp-integration/) off `src/serve/` internals (#8084) by RESOLVING each
|
||||
* import-like specifier against the importing file instead of matching
|
||||
* specifier text.
|
||||
*
|
||||
* Why resolution, not text: eight review rounds each demonstrated a new
|
||||
* spelling that escaped the regex/glob matrix (data: URLs, percent-encoded
|
||||
* segments, traversal through a leading literal segment, baseUrl bare
|
||||
* specifiers, createRequire/getBuiltinModule, TSImportType, vitest call
|
||||
* APIs, Worker/fork). Every one of those is just a different way to NAME
|
||||
* the same target — resolving collapses them into one check: does the
|
||||
* specifier land inside `packages/cli/src/serve/`?
|
||||
*
|
||||
* Fail-closed posture: anything that cannot be resolved statically
|
||||
* (computed sources, `data:` URLs, `file:` URLs outside serve, absolute
|
||||
* paths, traversal-bearing bare specifiers, `node:module` imports,
|
||||
* `process.getBuiltinModule`) is rejected in a guarded tree, because a
|
||||
* guarded tree has no legitimate business importing code it cannot name —
|
||||
* none of those shapes occurs anywhere in the guarded trees today.
|
||||
*
|
||||
* Path comparison is case-insensitive: case-variant spellings
|
||||
* (`../../Serve/index.js`) load serve/ on case-insensitive filesystems, so
|
||||
* over-reporting them on case-sensitive ones is the safe direction.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Resolved inside the serve tree: exact dir or something beneath it. */
|
||||
function isInServeDir(resolved, serveDir) {
|
||||
const r = resolved.toLowerCase();
|
||||
const s = serveDir.toLowerCase();
|
||||
return r === s || r.startsWith(s + path.sep.toLowerCase());
|
||||
}
|
||||
|
||||
/** Strip ?query/#fragment — Node and bundlers drop them when resolving. */
|
||||
function stripUrlSuffixes(specifier) {
|
||||
return specifier.split(/[?#]/)[0];
|
||||
}
|
||||
|
||||
/** vitest module-loading method names (member and destructured spellings). */
|
||||
const vitestLoaderNames = /^(?:mock|doMock|importActual|importMock)$/;
|
||||
|
||||
/** Decode percent-encoded segments (Node decodes when mapping to fs). */
|
||||
function decodeSpecifier(specifier) {
|
||||
try {
|
||||
return decodeURIComponent(specifier);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePath(candidate) {
|
||||
const resolved = path.resolve(candidate);
|
||||
try {
|
||||
return fs.realpathSync.native(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/** Concatenate a static template literal; undefined if it has expressions. */
|
||||
function staticTemplateValue(template) {
|
||||
if (template.expressions.length > 0) return undefined;
|
||||
return template.quasis.map((quasi) => quasi.value.cooked ?? '').join('');
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow imports that resolve into src/serve/ from guarded trees.',
|
||||
category: 'Best Practices',
|
||||
recommended: 'error',
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
/** Absolute path of the serve directory to protect. */
|
||||
serveDir: { type: 'string' },
|
||||
/** Absolute directory bare specifiers resolve against (baseUrl). */
|
||||
baseUrlDir: { type: 'string' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
serveBoundary:
|
||||
'This specifier resolves into src/serve/ internals, which the guarded trees must not reach (#8084). Route through a public boundary instead.',
|
||||
failClosed:
|
||||
'This import source cannot be resolved statically, so it cannot be checked against the serve/ boundary (#8084). Use a plain string-literal relative specifier.',
|
||||
moduleBuiltin:
|
||||
"Importing the 'module' builtin (or process.getBuiltinModule) in a guarded tree aliases require()/module access past the serve/ boundary (#8084). Import modules statically instead.",
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const options = context.options[0] ?? {};
|
||||
// Canonicalize BOTH comparison sides through realpath: candidates are
|
||||
// realpath'd in the resolution arms, so a never-canonicalized
|
||||
// serveDir/baseUrlDir mismatches them whenever the repo sits under a
|
||||
// symlinked ancestor (macOS /tmp, symlink-mounted workspaces) and the
|
||||
// guard fails open (#8084 review).
|
||||
const serveDir = options.serveDir
|
||||
? resolvePath(options.serveDir)
|
||||
: undefined;
|
||||
const baseUrlDir = options.baseUrlDir
|
||||
? resolvePath(options.baseUrlDir)
|
||||
: undefined;
|
||||
const filename = context.filename ?? context.getFilename();
|
||||
const fileDir = path.dirname(path.resolve(filename));
|
||||
|
||||
if (!serveDir) return {};
|
||||
|
||||
/**
|
||||
* Resolve one specifier string against the importing file. Returns
|
||||
* 'inside' (lands in serve/), 'outside' (resolves elsewhere), or
|
||||
* 'unknown' (cannot be resolved statically — fail closed).
|
||||
*/
|
||||
function classifySpecifier(raw) {
|
||||
if (typeof raw !== 'string' || raw.length === 0) return 'unknown';
|
||||
|
||||
// Node preprocesses every specifier the way the WHATWG URL parser
|
||||
// does before scheme detection: ASCII tab/LF/CR are removed ANYWHERE,
|
||||
// C0 controls and space are removed at the edges (`import(' DATA:…')`
|
||||
// and `import('\x01data:…')` still load), and backslashes normalize
|
||||
// to '/' — file: URLs are "special", so '..\\serve\\x.js' resolves
|
||||
// exactly like '../serve/x.js'. Scheme detection must use the same
|
||||
// normalized form or C0-prefixed data:/file: URLs slip past it.
|
||||
const normalized = raw.replace(/[\t\n\r]/g, '').replace(/\\/g, '/');
|
||||
const trimmed = normalized.replace(
|
||||
// The C0-control range is deliberate: it mirrors the WHATWG URL
|
||||
// parser's edge stripping, which is exactly what scheme detection
|
||||
// must reproduce here.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g,
|
||||
'',
|
||||
);
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (lower === 'module' || lower === 'node:module') return 'unknown';
|
||||
|
||||
// Other node: builtins never touch serve/.
|
||||
if (lower.startsWith('node:')) return 'outside';
|
||||
|
||||
// Node package-imports specifiers ('#name') need the package.json
|
||||
// "imports" map to resolve — fail closed. Must precede
|
||||
// stripUrlSuffixes, which splits on '#' and would eat the marker.
|
||||
if (trimmed.startsWith('#')) return 'unknown';
|
||||
|
||||
// data: URLs can embed imports of arbitrary files — a guarded tree
|
||||
// has no legitimate use for them.
|
||||
if (lower.startsWith('data:')) return 'unknown';
|
||||
|
||||
// file: URLs resolve to a concrete path, but a guarded tree does not
|
||||
// import by URL — fail closed unconditionally (even outside serve,
|
||||
// matching the fileoverview contract).
|
||||
if (lower.startsWith('file:')) {
|
||||
try {
|
||||
const resolved = resolvePath(
|
||||
fileURLToPath(stripUrlSuffixes(trimmed)),
|
||||
);
|
||||
return isInServeDir(resolved, serveDir) ? 'inside' : 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Root-absolute paths map straight to the filesystem — fail closed
|
||||
// unconditionally; guarded trees have no legitimate absolute-path
|
||||
// imports.
|
||||
if (trimmed.startsWith('/')) {
|
||||
const decoded = decodeSpecifier(stripUrlSuffixes(trimmed));
|
||||
if (decoded === undefined) return 'unknown';
|
||||
return isInServeDir(resolvePath(decoded), serveDir)
|
||||
? 'inside'
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
const cleaned = decodeSpecifier(stripUrlSuffixes(trimmed));
|
||||
if (cleaned === undefined) return 'unknown';
|
||||
|
||||
// Relative specifiers resolve against the importing file.
|
||||
if (cleaned.startsWith('./') || cleaned.startsWith('../')) {
|
||||
const resolved = resolvePath(path.join(fileDir, cleaned));
|
||||
return isInServeDir(resolved, serveDir) ? 'inside' : 'outside';
|
||||
}
|
||||
|
||||
// Bare specifiers: real packages resolve elsewhere, but a tsconfig
|
||||
// baseUrl (packages/cli) makes `src/serve/...` resolve into serve/
|
||||
// (round-8 entrance). A bare specifier carrying traversal cannot be
|
||||
// attributed to any package — fail closed. The resolution goes
|
||||
// through realpath like every other filesystem arm: a committable
|
||||
// symlink inside the baseUrl tree pointing into serve/ must not
|
||||
// classify 'outside' while tsc/esbuild follow it.
|
||||
if (cleaned.includes('../')) return 'unknown';
|
||||
if (baseUrlDir) {
|
||||
const resolved = resolvePath(path.resolve(baseUrlDir, cleaned));
|
||||
if (isInServeDir(resolved, serveDir)) return 'inside';
|
||||
}
|
||||
return 'outside';
|
||||
}
|
||||
|
||||
function reportInside(node) {
|
||||
context.report({ node, messageId: 'serveBoundary' });
|
||||
}
|
||||
|
||||
function reportUnknown(node, messageId = 'failClosed') {
|
||||
context.report({ node, messageId });
|
||||
}
|
||||
|
||||
/** Check a Literal/TemplateLiteral/computed source node. */
|
||||
function checkSource(sourceNode) {
|
||||
if (!sourceNode) return;
|
||||
let raw;
|
||||
if (sourceNode.type === 'Literal') {
|
||||
if (typeof sourceNode.value !== 'string') return; // not an import
|
||||
raw = sourceNode.value;
|
||||
} else if (sourceNode.type === 'TemplateLiteral') {
|
||||
raw = staticTemplateValue(sourceNode);
|
||||
if (raw === undefined) {
|
||||
reportUnknown(sourceNode);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
reportUnknown(sourceNode);
|
||||
return;
|
||||
}
|
||||
const verdict = classifySpecifier(raw);
|
||||
if (verdict === 'inside') reportInside(sourceNode);
|
||||
else if (verdict === 'unknown') reportUnknown(sourceNode);
|
||||
}
|
||||
|
||||
/** Static name of a member property node: Identifier for dot access,
|
||||
* string Literal or expression-free TemplateLiteral for computed
|
||||
* (`vi[`mock`]` is as resolvable as vi.mock). */
|
||||
function staticPropertyName(propertyNode, computed) {
|
||||
if (!computed) {
|
||||
return propertyNode.type === 'Identifier'
|
||||
? propertyNode.name
|
||||
: undefined;
|
||||
}
|
||||
if (
|
||||
propertyNode.type === 'Literal' &&
|
||||
typeof propertyNode.value === 'string'
|
||||
) {
|
||||
return propertyNode.value;
|
||||
}
|
||||
if (propertyNode.type === 'TemplateLiteral') {
|
||||
return staticTemplateValue(propertyNode) ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Rightmost member-segment name of an object expression:
|
||||
* `globalThis.vi` → 'vi', `x.cp` → 'cp', bare `vi` → 'vi'. Nested
|
||||
* member objects must not evade object-scoped arms. */
|
||||
function rightmostObjectName(objectNode) {
|
||||
if (objectNode.type === 'Identifier') return objectNode.name;
|
||||
if (objectNode.type === 'MemberExpression') {
|
||||
return staticPropertyName(objectNode.property, objectNode.computed);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Member-call shape: obj.prop(...); pass objectNames null to match
|
||||
* ANY object shape (alias-proof — the caller asserts safety). */
|
||||
function memberCall(callee, objectNames, propertyPattern) {
|
||||
if (callee.type !== 'MemberExpression') return false;
|
||||
const property = staticPropertyName(callee.property, callee.computed);
|
||||
if (property === undefined || !propertyPattern.test(property)) {
|
||||
return false;
|
||||
}
|
||||
if (objectNames === null) return true;
|
||||
const objectName = rightmostObjectName(callee.object);
|
||||
return objectName !== undefined && objectNames.includes(objectName);
|
||||
}
|
||||
|
||||
function isProcessObject(node) {
|
||||
if (node.type === 'Identifier') return node.name === 'process';
|
||||
if (node.type !== 'MemberExpression') return false;
|
||||
const objectName = rightmostObjectName(node.object);
|
||||
return (
|
||||
(objectName === 'globalThis' || objectName === 'global') &&
|
||||
staticPropertyName(node.property, node.computed) === 'process'
|
||||
);
|
||||
}
|
||||
|
||||
/** getBuiltinModule as a property name — any statically resolvable
|
||||
* spelling. */
|
||||
function builtinModuleProperty(memberExpr) {
|
||||
return (
|
||||
staticPropertyName(memberExpr.property, memberExpr.computed) ===
|
||||
'getBuiltinModule'
|
||||
);
|
||||
}
|
||||
|
||||
// Renamed module-loading bindings resolved from the import
|
||||
// declarations of this file: `import { fork as f }`,
|
||||
// `import { Worker as W }`, vm surfaces. Anything unresolvable stays
|
||||
// out of these sets (documented residue, not fail-closed bait).
|
||||
const forkAliases = new Set();
|
||||
const workerAliases = new Set();
|
||||
const scriptAliases = new Set();
|
||||
const vmObjectNames = new Set(['vm']);
|
||||
const vmExecNames =
|
||||
/^(?:runInThisContext|runInNewContext|runInContext|compileFunction)$/;
|
||||
const vmBareExecAliases = new Set();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const value = node.source?.value;
|
||||
// The `module` builtin hands out createRequire, which aliases
|
||||
// require() past every import-shaped guard (round-7 entrance).
|
||||
if (typeof value === 'string' && /^(?:node:)?module$/.test(value)) {
|
||||
reportUnknown(node.source, 'moduleBuiltin');
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const bare = value.startsWith('node:') ? value.slice(5) : value;
|
||||
for (const spec of node.specifiers) {
|
||||
const imported =
|
||||
spec.type === 'ImportSpecifier'
|
||||
? (spec.imported?.name ?? spec.imported?.value)
|
||||
: undefined;
|
||||
if (bare === 'child_process' && imported === 'fork') {
|
||||
forkAliases.add(spec.local.name);
|
||||
} else if (bare === 'worker_threads' && imported === 'Worker') {
|
||||
workerAliases.add(spec.local.name);
|
||||
} else if (bare === 'vm') {
|
||||
if (spec.type === 'ImportSpecifier') {
|
||||
if (imported === 'Script') scriptAliases.add(spec.local.name);
|
||||
else if (vmExecNames.test(imported ?? '')) {
|
||||
vmBareExecAliases.add(spec.local.name);
|
||||
}
|
||||
} else {
|
||||
// default or namespace import — usable as the vm object
|
||||
vmObjectNames.add(spec.local.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
checkSource(node.source);
|
||||
},
|
||||
ExportNamedDeclaration(node) {
|
||||
if (node.source) checkSource(node.source);
|
||||
},
|
||||
ExportAllDeclaration(node) {
|
||||
checkSource(node.source);
|
||||
},
|
||||
ImportExpression(node) {
|
||||
checkSource(node.source);
|
||||
},
|
||||
// Type-level imports: import('../serve/x.js') inside a type position.
|
||||
TSImportType(node) {
|
||||
const literal = node.argument?.literal;
|
||||
if (literal) checkSource(literal);
|
||||
},
|
||||
// import x = require('../serve/x.js') — tsc under NodeNext emits a
|
||||
// working createRequire shim for this spelling, so it loads at
|
||||
// runtime despite looking type-ish (sibling of the require visitor).
|
||||
TSImportEqualsDeclaration(node) {
|
||||
if (node.moduleReference?.type === 'TSExternalModuleReference') {
|
||||
checkSource(node.moduleReference.expression);
|
||||
}
|
||||
},
|
||||
CallExpression(node) {
|
||||
// `(0, x)(...)` resolves to the last sequence element — unwrap
|
||||
// once, uniformly, before every callee-shape check below.
|
||||
const callee =
|
||||
node.callee.type === 'SequenceExpression'
|
||||
? node.callee.expressions[node.callee.expressions.length - 1]
|
||||
: node.callee;
|
||||
|
||||
// Function.prototype.call/apply/bind indirection on guarded
|
||||
// callees: `.call` unwraps like a direct call with the specifier
|
||||
// shifted one argument right; `.apply`/`.bind` forward their
|
||||
// arguments in shapes this rule does not resolve — fail closed
|
||||
// (same treatment Reflect.apply already gets).
|
||||
if (callee.type === 'MemberExpression') {
|
||||
const indirect = staticPropertyName(callee.property, callee.computed);
|
||||
if (
|
||||
indirect === 'call' ||
|
||||
indirect === 'apply' ||
|
||||
indirect === 'bind'
|
||||
) {
|
||||
const innerName = rightmostObjectName(callee.object);
|
||||
if (innerName === 'eval') {
|
||||
// The forwarded argument is code, not a specifier.
|
||||
reportUnknown(node);
|
||||
return;
|
||||
}
|
||||
if (innerName === 'getBuiltinModule') {
|
||||
if (node.arguments.length > 0) {
|
||||
reportUnknown(node, 'moduleBuiltin');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
/^(?:require|fork|mock|doMock|importActual|importMock)$/.test(
|
||||
innerName ?? '',
|
||||
)
|
||||
) {
|
||||
if (indirect === 'call') {
|
||||
if (node.arguments.length > 1) {
|
||||
checkSource(node.arguments[1]);
|
||||
}
|
||||
} else {
|
||||
reportUnknown(node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// String-code execution class: any call whose callee ends in the
|
||||
// `eval` or `Function` identifier — direct, sequence-unwrapped,
|
||||
// or member spellings (globalThis.eval, globalThis.Function) —
|
||||
// plus `.constructor` property chains, which reach the Function
|
||||
// constructor WITHOUT naming it (({}).constructor.constructor,
|
||||
// (function(){}).constructor, AsyncFunction variants). All
|
||||
// compile/execute arbitrary string code that can import()
|
||||
// anything; the source is visible but unresolvable, so fail
|
||||
// closed like computed sources. `.constructor` is only a code
|
||||
// shape when called WITH a string argument; eval/Function fail
|
||||
// closed on any argument.
|
||||
const calleeName =
|
||||
callee.type === 'Identifier'
|
||||
? callee.name
|
||||
: callee.type === 'MemberExpression'
|
||||
? staticPropertyName(callee.property, callee.computed)
|
||||
: undefined;
|
||||
if (
|
||||
node.arguments.length > 0 &&
|
||||
(calleeName === 'eval' ||
|
||||
calleeName === 'Function' ||
|
||||
calleeName === 'constructor')
|
||||
) {
|
||||
if (calleeName === 'constructor') {
|
||||
const first = node.arguments[0];
|
||||
const stringCode =
|
||||
(first.type === 'Literal' && typeof first.value === 'string') ||
|
||||
(first.type === 'TemplateLiteral' &&
|
||||
staticTemplateValue(first) !== undefined);
|
||||
if (stringCode) reportUnknown(node);
|
||||
} else {
|
||||
reportUnknown(node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// node:vm string-execution surface — runInThisContext /
|
||||
// runInNewContext / runInContext / compileFunction compile or run
|
||||
// arbitrary string code. Scoped to vm imports (default/namespace
|
||||
// objects and renamed named imports) plus the bare `vm` name.
|
||||
if (node.arguments.length > 0) {
|
||||
const vmObjectName =
|
||||
callee.type === 'MemberExpression'
|
||||
? rightmostObjectName(callee.object)
|
||||
: undefined;
|
||||
const vmProperty =
|
||||
callee.type === 'MemberExpression'
|
||||
? staticPropertyName(callee.property, callee.computed)
|
||||
: undefined;
|
||||
if (
|
||||
(vmProperty !== undefined &&
|
||||
vmExecNames.test(vmProperty) &&
|
||||
vmObjectName !== undefined &&
|
||||
vmObjectNames.has(vmObjectName)) ||
|
||||
(callee.type === 'Identifier' && vmBareExecAliases.has(callee.name))
|
||||
) {
|
||||
reportUnknown(node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// vi.mock / vi.doMock / vi.importActual / vi.importMock — vitest
|
||||
// resolves (and, without a factory, loads) the real module. The
|
||||
// object is deliberately NOT matched (rightmost-segment matching
|
||||
// covers `globalThis.vi`, `vitest.vi`, nested member objects):
|
||||
// aliased spellings evade identifier checks (round-8 entrance),
|
||||
// and the guarded trees contain no non-vitest callers with these
|
||||
// method names. Only specifiers resolving INTO serve/ report, so
|
||||
// this cannot false-positive on other packages' modules.
|
||||
if (
|
||||
memberCall(callee, null, vitestLoaderNames) &&
|
||||
node.arguments.length > 0
|
||||
) {
|
||||
checkSource(node.arguments[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// require('...')
|
||||
if (
|
||||
callee.type === 'Identifier' &&
|
||||
callee.name === 'require' &&
|
||||
node.arguments.length > 0
|
||||
) {
|
||||
checkSource(node.arguments[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bare-identifier module-loading calls — the destructured spelling
|
||||
// `import { importActual } from 'vitest'; importActual(...)`. Same
|
||||
// rationale as the member form; we only report when the specifier
|
||||
// resolves INTO serve/, so a non-vitest loader of a non-serve module
|
||||
// is never flagged.
|
||||
if (
|
||||
callee.type === 'Identifier' &&
|
||||
vitestLoaderNames.test(callee.name) &&
|
||||
node.arguments.length > 0
|
||||
) {
|
||||
checkSource(node.arguments[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// process.getBuiltinModule(...) hands out module objects
|
||||
// (createRequire) without any import statement (round-8 entrance).
|
||||
// isProcessObject covers `process`, `globalThis.process` and
|
||||
// `global.process` in every statically resolvable property
|
||||
// spelling; builtinModuleProperty likewise; the bare identifier
|
||||
// is the destructured spelling; Reflect indirection is unwrapped
|
||||
// below.
|
||||
if (
|
||||
(callee.type === 'MemberExpression' &&
|
||||
isProcessObject(callee.object) &&
|
||||
builtinModuleProperty(callee)) ||
|
||||
(callee.type === 'Identifier' && callee.name === 'getBuiltinModule')
|
||||
) {
|
||||
reportUnknown(node, 'moduleBuiltin');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reflect.apply / Reflect.construct with a guarded target: the
|
||||
// arguments travel inside an array this rule does not resolve —
|
||||
// fail closed (the getBuiltinModule target keeps its messageId).
|
||||
if (memberCall(callee, ['Reflect'], /^(?:apply|construct)$/)) {
|
||||
const target = node.arguments[0];
|
||||
const targetMember = target?.type === 'MemberExpression';
|
||||
const targetName = targetMember
|
||||
? staticPropertyName(target.property, target.computed)
|
||||
: target?.type === 'Identifier'
|
||||
? target.name
|
||||
: undefined;
|
||||
if (
|
||||
targetMember &&
|
||||
isProcessObject(target.object) &&
|
||||
builtinModuleProperty(target)
|
||||
) {
|
||||
reportUnknown(node, 'moduleBuiltin');
|
||||
} else if (
|
||||
targetMember
|
||||
? /^(?:fork|eval)$/.test(targetName ?? '')
|
||||
: /^(?:require|eval|fork)$/.test(targetName ?? '') ||
|
||||
targetName === 'Worker' ||
|
||||
workerAliases.has(targetName ?? '') ||
|
||||
forkAliases.has(targetName ?? '')
|
||||
) {
|
||||
reportUnknown(node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// child_process.fork loads a module path (resolved relative to the
|
||||
// importing file as the best static approximation; the guarded
|
||||
// trees have no such calls today). spawn is deliberately NOT
|
||||
// checked: its first argument is an executable resolved via
|
||||
// PATH/cwd, not a module — flagging it would false-positive on
|
||||
// legitimate code like spawn(process.execPath, [...]). The member
|
||||
// match is object-agnostic (same tradeoff as the vitest loaders:
|
||||
// `import cp from 'node:child_process'; cp.fork(...)` and the
|
||||
// namespace form must not evade the guard), the bare identifier
|
||||
// covers destructured `fork`, and forkAliases covers renamed
|
||||
// imports; only specifiers resolving INTO serve/ report, so a
|
||||
// non-serve fork target is never flagged.
|
||||
if (
|
||||
(memberCall(callee, null, /^fork$/) ||
|
||||
(callee.type === 'Identifier' &&
|
||||
(callee.name === 'fork' || forkAliases.has(callee.name)))) &&
|
||||
node.arguments.length > 0
|
||||
) {
|
||||
checkSource(node.arguments[0]);
|
||||
return;
|
||||
}
|
||||
},
|
||||
NewExpression(node) {
|
||||
const callee =
|
||||
node.callee.type === 'SequenceExpression'
|
||||
? node.callee.expressions[node.callee.expressions.length - 1]
|
||||
: node.callee;
|
||||
|
||||
// new Function(body) / new globalThis.Function(body) compiles
|
||||
// arbitrary string code that can import() anything — fail closed
|
||||
// like computed sources (eval's sibling).
|
||||
if (
|
||||
node.arguments.length > 0 &&
|
||||
((callee.type === 'Identifier' && callee.name === 'Function') ||
|
||||
(callee.type === 'MemberExpression' &&
|
||||
staticPropertyName(callee.property, callee.computed) ===
|
||||
'Function'))
|
||||
) {
|
||||
reportUnknown(node);
|
||||
return;
|
||||
}
|
||||
|
||||
// new vm.Script(code) / new Script(code) — string-code
|
||||
// compilation, same class as Function (vm import spellings).
|
||||
if (
|
||||
node.arguments.length > 0 &&
|
||||
((callee.type === 'MemberExpression' &&
|
||||
staticPropertyName(callee.property, callee.computed) === 'Script' &&
|
||||
vmObjectNames.has(rightmostObjectName(callee.object) ?? '')) ||
|
||||
(callee.type === 'Identifier' && scriptAliases.has(callee.name)))
|
||||
) {
|
||||
reportUnknown(node);
|
||||
return;
|
||||
}
|
||||
|
||||
// new Worker('../serve/...') / new wt.Worker('...') / new W
|
||||
// (renamed import) — string module paths resolve relative to the
|
||||
// importing module (worker_threads does the same). Object-agnostic
|
||||
// member match covers namespace/default-import spellings.
|
||||
const workerCallee =
|
||||
callee.type === 'Identifier'
|
||||
? callee.name === 'Worker' || workerAliases.has(callee.name)
|
||||
: callee.type === 'MemberExpression' &&
|
||||
staticPropertyName(callee.property, callee.computed) === 'Worker';
|
||||
if (workerCallee && node.arguments.length > 0) {
|
||||
// new Worker(codeString, { eval: true }) executes arg0 as CODE,
|
||||
// not as a specifier. Fail closed unless the option is
|
||||
// statically false (a dynamic option or a non-object second
|
||||
// argument cannot be verified).
|
||||
const opts = node.arguments[1];
|
||||
if (opts) {
|
||||
const evalProp =
|
||||
opts.type === 'ObjectExpression' &&
|
||||
opts.properties.find(
|
||||
(property) =>
|
||||
property.type === 'Property' &&
|
||||
staticPropertyName(property.key, property.computed) ===
|
||||
'eval',
|
||||
);
|
||||
const staticallyFalse =
|
||||
evalProp &&
|
||||
evalProp.value.type === 'Literal' &&
|
||||
evalProp.value.value === false;
|
||||
if (!staticallyFalse) {
|
||||
reportUnknown(node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// new Worker(new URL(spec, import.meta.url)) is resolved by the
|
||||
// new-URL arm below; checking it here too would fail-close a
|
||||
// fully static, boundary-clean construct and double-report the
|
||||
// serve-targeting form.
|
||||
const arg = node.arguments[0];
|
||||
const handledByUrlArm =
|
||||
arg.type === 'NewExpression' &&
|
||||
arg.callee.type === 'Identifier' &&
|
||||
arg.callee.name === 'URL' &&
|
||||
arg.arguments.length >= 2 &&
|
||||
arg.arguments[1].type === 'MemberExpression' &&
|
||||
arg.arguments[1].object.type === 'MetaProperty' &&
|
||||
staticPropertyName(
|
||||
arg.arguments[1].property,
|
||||
arg.arguments[1].computed,
|
||||
) === 'url';
|
||||
if (!handledByUrlArm) checkSource(arg);
|
||||
return;
|
||||
}
|
||||
|
||||
// new URL('../serve/...', import.meta.url) — Worker/asset loads
|
||||
// (round-8 entrance). The base argument is a MemberExpression
|
||||
// wrapping the import.meta MetaProperty; resolve the first
|
||||
// argument against this module. Only import.meta.URL is a
|
||||
// statically known base — import.meta.<anything else> cannot be
|
||||
// resolved, so fail closed instead of assuming the module base.
|
||||
if (
|
||||
callee.type === 'Identifier' &&
|
||||
callee.name === 'URL' &&
|
||||
node.arguments.length >= 2 &&
|
||||
node.arguments[1].type === 'MemberExpression' &&
|
||||
node.arguments[1].object.type === 'MetaProperty'
|
||||
) {
|
||||
if (
|
||||
staticPropertyName(
|
||||
node.arguments[1].property,
|
||||
node.arguments[1].computed,
|
||||
) === 'url'
|
||||
) {
|
||||
checkSource(node.arguments[0]);
|
||||
} else {
|
||||
reportUnknown(node);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
<EFBFBD>٩<EFBFBD><EFBFBD>hoV<EFBFBD>
|
||||
|
|
@ -1,884 +1 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ESLint } from 'eslint';
|
||||
import { rmSync, symlinkSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..',
|
||||
);
|
||||
|
||||
const eslint = new ESLint({ cwd: repoRoot });
|
||||
|
||||
const RULE_ID = 'qwen-boundary/no-serve-boundary-cross';
|
||||
const ACP_FIXTURE = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
const RUNTIME_FIXTURE = 'packages/cli/src/runtime/boundary-fixture.ts';
|
||||
|
||||
const lintCliFile = (filePath, code) =>
|
||||
eslint.lintText(code, { filePath: path.join(repoRoot, filePath) });
|
||||
|
||||
/** Assert the boundary rule fired for `code`. Filters on the rule id,
|
||||
* not a 'serve' substring: every one of the rule's three messageIds
|
||||
* contains 'serve', and so do unrelated diagnostics — the substring
|
||||
* could not tell the rule firing from any other noise (#8084 review). */
|
||||
const expectServeBoundaryError = async (filePath, code) => {
|
||||
const [result] = await lintCliFile(filePath, code);
|
||||
expect(result.messages.some((message) => message.ruleId === RULE_ID)).toBe(
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
/** Assert the boundary rule produced NO diagnostics for `code`. Filters on
|
||||
* the rule id (stricter than a 'serve' substring: also catches failClosed
|
||||
* over-blocking from this rule). */
|
||||
const expectNoBoundaryHits = async (filePath, code) => {
|
||||
const [result] = await lintCliFile(filePath, code);
|
||||
const boundaryHits = result.messages.filter(
|
||||
(message) => message.ruleId === RULE_ID,
|
||||
);
|
||||
expect(boundaryHits).toEqual([]);
|
||||
};
|
||||
|
||||
describe('eslint cli serve boundary rules', () => {
|
||||
it('rejects static and dynamic serve imports from runtime', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/runtime/boundary-fixture.ts',
|
||||
"import '../serve/index.js';",
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/runtime/boundary-fixture.ts',
|
||||
"export async function load() { await import('../serve/index.js'); }",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects acp dynamic serve imports through template and traversal paths', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/acp-integration/boundary-fixture.ts',
|
||||
'export async function load() { await import(`../serve/acp-http/dispatch.js`); }',
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/acp-integration/boundary-fixture.ts',
|
||||
"export async function load() { await import('../runtime/../serve/index.js'); }",
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/acp-integration/boundary-fixture.ts',
|
||||
"export async function load() { await import('./../serve/index.js'); }",
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/acp-integration/boundary-fixture.ts',
|
||||
"import '../serve/index.js';",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects static and dynamic serve imports from utils', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/utils/boundary-fixture.ts',
|
||||
"import '../serve/index.js';",
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/utils/boundary-fixture.ts',
|
||||
"export async function load() { await import('../serve/index.js'); }",
|
||||
);
|
||||
});
|
||||
|
||||
// R5-4: pins the bare-directory specifier (`../serve` resolves to the
|
||||
// serve/ barrel) for both static and dynamic forms in utils/ — reverting
|
||||
// the bare-entry hunk must turn this red.
|
||||
it('rejects the bare serve barrel specifier', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/utils/boundary-fixture.ts',
|
||||
"import '../serve';",
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/runtime/boundary-fixture.ts',
|
||||
"export async function load() { await import('../serve'); }",
|
||||
);
|
||||
});
|
||||
|
||||
// R4-1: the per-spelling regex entrances demonstrated in round 4 —
|
||||
// duplicated separators, traversal through intermediate segments,
|
||||
// concatenated sources, `new URL(...)` sources, and type-level imports.
|
||||
it('rejects non-canonical and computed dynamic serve imports', async () => {
|
||||
const runtime = 'packages/cli/src/runtime/boundary-fixture.ts';
|
||||
|
||||
await expectServeBoundaryError(
|
||||
runtime,
|
||||
"export async function load() { await import('..//serve/index.js'); }",
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
runtime,
|
||||
"export async function load() { await import('../foo/../serve/index.js'); }",
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
runtime,
|
||||
"export async function load() { await import('../serve/' + 'index.js'); }",
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
runtime,
|
||||
'export async function load() { await import(new URL("../serve/index.js", import.meta.url)); }',
|
||||
);
|
||||
|
||||
await expectServeBoundaryError(
|
||||
runtime,
|
||||
'export type Leak = import("../serve/live/types.js").Leak;',
|
||||
);
|
||||
});
|
||||
|
||||
// R5-5: the general packages/**/src/** block supplies
|
||||
// restrictedStringThrow; the guarded-tree override blocks only ADD the
|
||||
// boundary rule. This probe pins that the general block's rule still
|
||||
// applies inside the guarded trees despite those overrides.
|
||||
it('still rejects string throws inside the guarded overrides', async () => {
|
||||
const [result] = await lintCliFile(
|
||||
'packages/cli/src/acp-integration/boundary-fixture.ts',
|
||||
"export function boom() { throw 'boom'; }",
|
||||
);
|
||||
expect(result.messages.map((message) => message.message)).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('throw')]),
|
||||
);
|
||||
});
|
||||
|
||||
// Round 6: the depth-enumeration loop must stay pinned beyond depth 1 —
|
||||
// real acp-integration files reach serve via `../../serve/...` (depth 2),
|
||||
// so a fixture at that depth turns a regressed loop bound red.
|
||||
it('rejects static serve imports from a depth-2 guarded file', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/acp-integration/session/boundary-fixture.ts',
|
||||
"import '../../serve/index.js';",
|
||||
);
|
||||
});
|
||||
|
||||
// Round 6: type-level imports wrap the specifier in a TSLiteralType; the
|
||||
// selector must read argument.literal.value. Legitimate type imports of
|
||||
// third-party modules must stay clean.
|
||||
it('flags serve type imports but allows legitimate typeof imports', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/runtime/boundary-fixture.ts',
|
||||
'export type Leak = import("../serve/live/types.js").Leak;',
|
||||
);
|
||||
|
||||
const [result] = await lintCliFile(
|
||||
'packages/cli/src/runtime/boundary-fixture.ts',
|
||||
"export type UndiciModule = typeof import('undici');",
|
||||
);
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
// Round 6: template literals containing expressions are computed sources
|
||||
// and are rejected fail-closed (pure-literal template forms are resolved
|
||||
// like string literals instead).
|
||||
it('rejects computed template-literal dynamic imports fail-closed', async () => {
|
||||
const [result] = await lintCliFile(
|
||||
'packages/cli/src/runtime/boundary-fixture.ts',
|
||||
'export async function load(base: string) { await import(`${base}/serve/x.js`); }',
|
||||
);
|
||||
expect(result.messages.map((message) => message.message)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('cannot be resolved statically'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// Round 6 (remaining entrances): percent-encoded segments, static
|
||||
// traversal twins, and the leading-literal-segment dynamic spelling.
|
||||
it('rejects percent-encoded and static-traversal boundary entrances', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
|
||||
// Node percent-decodes segments when mapping to the filesystem, so
|
||||
// raw-text patterns cannot see through %73 === 's'.
|
||||
await expectServeBoundaryError(acp, "import '../%73erve/index.js';");
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"export async function load() { await import('../%73erve/index.js'); }",
|
||||
);
|
||||
|
||||
// Static twins of the blocked dynamic spellings.
|
||||
await expectServeBoundaryError(acp, "import './../serve/index.js';");
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"import '../runtime/../serve/index.js';",
|
||||
);
|
||||
await expectServeBoundaryError(acp, "import '..//serve/index.js';");
|
||||
});
|
||||
|
||||
it('rejects a leading literal segment before the traversal run', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/acp-integration/boundary-fixture.ts',
|
||||
"export async function load() { await import('foo/../../serve/index.js'); }",
|
||||
);
|
||||
});
|
||||
|
||||
// vitest module-loading calls resolve (and without a factory load) the
|
||||
// real module, so the boundary applies to them too.
|
||||
it('rejects serve specifiers in vitest module-loading calls', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"vi.mock('../serve/live/live-task-service.js');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"export async function load() { return vi.importActual('../serve/live/live-task-service.js'); }",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"vitest.mock('../serve/live/live-task-service.js');",
|
||||
);
|
||||
|
||||
// A non-serve vi.mock stays silent on the boundary.
|
||||
await expectNoBoundaryHits(acp, "vi.mock('../utils/foo.js');");
|
||||
});
|
||||
|
||||
// Round-7 entrances (#8084): each spelling below resolves to serve/
|
||||
// while evading the relative patterns; every one is pinned here.
|
||||
it('rejects case-variant serve spellings', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(acp, "import '../Serve/index.js';");
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"export async function load() { return import('../Serve/live/live-task-service.js'); }",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"vi.mock('../SERVE/live/live-task-service.js');",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects ?query and #fragment suffixes on serve specifiers', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(acp, "import '../serve/index.js?x';");
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"export async function load() { return import('../serve/index.js?x'); }",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"vi.mock('../serve/live/live-task-service.js?x');",
|
||||
);
|
||||
await expectServeBoundaryError(acp, "import '../serve/index.js#f';");
|
||||
});
|
||||
|
||||
it('rejects percent-encoded pure-template vitest calls', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
'vi.mock(`../%73erve/live/live-task-service.js`);',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects root-absolute and file: literal specifiers fail-closed', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"import '/srv/qwen/packages/cli/src/serve/index.js';",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"import 'file:///srv/qwen/packages/cli/src/serve/index.js';",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"export async function load() { return import('/srv/qwen/packages/cli/src/serve/index.js'); }",
|
||||
);
|
||||
});
|
||||
|
||||
it('flags createRequire source modules in guarded trees', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"import { createRequire } from 'node:module';",
|
||||
);
|
||||
await expectServeBoundaryError(acp, "import moduleBuiltin from 'module';");
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"export { createRequire } from 'node:module';",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"export async function load() { return import('node:module'); }",
|
||||
);
|
||||
});
|
||||
|
||||
// Round-8 entrances (#8084): each spelling below reached serve/ while
|
||||
// evading the old text-matching matrix entirely; the resolution-based
|
||||
// rule collapses them into the same "lands in serve/" check.
|
||||
it('rejects data: URL imports fail-closed', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
'export async function load() { await import("data:text/javascript,export*from\\"file:///repo/packages/cli/src/serve/index.js\\""); }',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects baseUrl bare specifiers that resolve into serve', async () => {
|
||||
// packages/cli tsconfig baseUrl "." makes `src/serve/...` a valid
|
||||
// bare-specifier import — text patterns never saw a `../` run here.
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(acp, "import 'src/serve/index.js';");
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"export async function load() { return import('src/serve/live/live-task-service.js'); }",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects traversal-bearing bare specifiers fail-closed', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/acp-integration/boundary-fixture.ts',
|
||||
"import 'foo/../../src/serve/index.js';",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects process.getBuiltinModule in guarded trees fail-closed', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"const mod = process.getBuiltinModule('node:module');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"const mod = process['getBuiltinModule']('node:module');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"const mod = globalThis.process.getBuiltinModule('node:module');",
|
||||
);
|
||||
});
|
||||
|
||||
// Codex self-review: URL schemes are case-insensitive — `FILE:`/`DATA:`
|
||||
// must fail closed just like their lowercase forms.
|
||||
it('rejects case-variant file:/data: URL schemes fail-closed', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"import 'FILE:///repo/packages/cli/src/serve/index.js';",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"export async function load() { await import('DATA:text/javascript,export default 1'); }",
|
||||
);
|
||||
});
|
||||
|
||||
// The URL parser strips surrounding whitespace, so ` DATA:...` loads the
|
||||
// same way — scheme detection must trim before matching.
|
||||
it('rejects whitespace-padded URL scheme spellings fail-closed', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/acp-integration/boundary-fixture.ts',
|
||||
"export async function load() { await import(' DATA:text/javascript,export default 1'); }",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects control-character and symlinked serve paths', async () => {
|
||||
const utils = 'packages/cli/src/utils/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
utils,
|
||||
"export async function load() { await import('../ser\\tve/index.js'); }",
|
||||
);
|
||||
|
||||
const link = path.join(repoRoot, 'packages/cli/src/utils/serve-link.js');
|
||||
rmSync(link, { force: true });
|
||||
try {
|
||||
symlinkSync('../serve/index.ts', link);
|
||||
await expectServeBoundaryError(
|
||||
utils,
|
||||
"export async function load() { await import('./serve-link.js'); }",
|
||||
);
|
||||
} finally {
|
||||
rmSync(link, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Codex self-review: vitest loaders reached through an alias evade the
|
||||
// `vi.`/`vitest.` identifier match; the member/bare-name matchers must
|
||||
// still catch them when the specifier resolves into serve/.
|
||||
it('rejects aliased vitest module-loading calls into serve', async () => {
|
||||
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"import { vi as v } from 'vitest';\nv.mock('../serve/live/live-task-service.js');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"import { importActual } from 'vitest';\nexport async function load() { return importActual('../serve/live/live-task-service.js'); }",
|
||||
);
|
||||
// R8-2: doMock/importMock were matched by the guard but had zero
|
||||
// fixture coverage — narrowing the alternation stayed green.
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"import { vi } from 'vitest';\nvi.doMock('../serve/live/live-task-service.js');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
acp,
|
||||
"import { vi } from 'vitest';\nvi.importMock('../serve/live/live-task-service.js');",
|
||||
);
|
||||
});
|
||||
|
||||
// Codex self-review: child_process.spawn's first argument is an
|
||||
// executable resolved via PATH/cwd, not a module — it must NOT be
|
||||
// treated as an import source (would false-positive legitimate code).
|
||||
it('does not treat child_process.spawn arguments as import sources', async () => {
|
||||
await expectNoBoundaryHits(
|
||||
ACP_FIXTURE,
|
||||
"import { spawn } from 'node:child_process';\nexport function run() { return spawn(process.execPath, ['--version']); }",
|
||||
);
|
||||
});
|
||||
|
||||
// R5-7: third-party packages whose name contains `serve` must not be
|
||||
// caught by the boundary (the old `**/serve*` globs matched them).
|
||||
it('allows third-party serve-named packages', async () => {
|
||||
const code = [
|
||||
"import handler from 'serve';",
|
||||
"import scoped from '@scope/serve';",
|
||||
"import sub from '@scope/serve/handler.js';",
|
||||
'',
|
||||
].join('\n');
|
||||
// R10-3: filter on the rule itself, not the serveBoundary text — a
|
||||
// regression routing serve-named bare specifiers to failClosed must
|
||||
// also turn this pin red ('serve/ internals' is absent from the
|
||||
// failClosed message).
|
||||
await expectNoBoundaryHits(ACP_FIXTURE, code);
|
||||
});
|
||||
|
||||
// R9-5: the re-export / Worker / fork / require visitors had no fixture
|
||||
// pins — deleting any of them left the suite green. The bare `fork`
|
||||
// spelling also covers R9-4 (the destructured child_process import
|
||||
// evaded the member-only guard).
|
||||
it('pins re-export, Worker, fork and require entrances', async () => {
|
||||
const runtime = 'packages/cli/src/runtime/boundary-fixture.ts';
|
||||
await expectServeBoundaryError(
|
||||
runtime,
|
||||
"export * from '../serve/index.js';",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
runtime,
|
||||
"export { x } from '../serve/index.js';",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
runtime,
|
||||
"new Worker('../serve/worker.js');",
|
||||
);
|
||||
await expectServeBoundaryError(runtime, "require('../serve/index.js');");
|
||||
await expectServeBoundaryError(
|
||||
runtime,
|
||||
"import { fork } from 'node:child_process';\nfork('../serve/index.js');",
|
||||
);
|
||||
});
|
||||
|
||||
// R9-2: the new-URL-with-import.meta check sat in the CallExpression
|
||||
// visitor (NewExpression nodes never dispatch there), so a standalone
|
||||
// `new URL('../serve/...', import.meta.url)` reported nothing.
|
||||
it('rejects standalone new URL(spec, import.meta.url) into serve', async () => {
|
||||
await expectServeBoundaryError(
|
||||
'packages/cli/src/runtime/boundary-fixture.ts',
|
||||
"const u = new URL('../serve/worker.js', import.meta.url);",
|
||||
);
|
||||
});
|
||||
|
||||
// R9-7: no pin exercised the false branch of static-template
|
||||
// concatenation — a pure template literal resolving OUTSIDE serve must
|
||||
// stay allowed (breaking the concatenation fail-closes legitimate code).
|
||||
it('allows pure template-literal imports that resolve outside serve', async () => {
|
||||
await expectNoBoundaryHits(
|
||||
ACP_FIXTURE,
|
||||
'export async function load() { await import(`../utils/boundary-fixture.ts`); }',
|
||||
);
|
||||
});
|
||||
|
||||
// R13-2: resolution-based detections must report via the serveBoundary
|
||||
// messageId — if inside-detection degrades into blanket fail-closed
|
||||
// rejection the substring-based positive helper stays green, so pin the
|
||||
// messageId directly.
|
||||
it('reports resolution detections via the serveBoundary messageId', async () => {
|
||||
for (const code of [
|
||||
"import '../serve/index.js';",
|
||||
"export async function load() { return import('src/serve/index.js'); }",
|
||||
]) {
|
||||
const [result] = await lintCliFile(ACP_FIXTURE, code);
|
||||
expect(
|
||||
result.messages.some(
|
||||
(message) => message.messageId === 'serveBoundary',
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Round-11 review pins ─────────────────────────────────────────────
|
||||
|
||||
// R12-2 (round-9 ledger): the '#' fail-closed check used to sit AFTER
|
||||
// stripUrlSuffixes, which splits on '#' — '#name' collapsed to '' and
|
||||
// classified outside, so package-imports specifiers sailed through. The
|
||||
// check now precedes suffix stripping; pin both entrances.
|
||||
it('fails closed on package-imports (#) specifiers', async () => {
|
||||
await expectServeBoundaryError(ACP_FIXTURE, "import '#s';");
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"export async function load() { return import('#serve-internals'); }",
|
||||
);
|
||||
});
|
||||
|
||||
// C0 controls at the specifier edges are stripped before Node's scheme
|
||||
// detection — '\x01data:…' still loads a data: URL. Scheme detection
|
||||
// must see the same edge-stripped form.
|
||||
it('fails closed on C0-control-prefixed URL schemes', async () => {
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"export async function load() { await import('\\u0001data:text/javascript,export default 1'); }",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"import '\\u0001file:///repo/packages/cli/src/serve/index.js';",
|
||||
);
|
||||
});
|
||||
|
||||
// String-code execution entrances embed import('…') the rule cannot
|
||||
// resolve — fail closed like computed sources (eval/new Function have
|
||||
// no shared no-eval guard in the config).
|
||||
it('fails closed on string-code execution entrances', async () => {
|
||||
for (const code of [
|
||||
'eval("import(\'../serve/index.js\')");',
|
||||
'(0, eval)("import(\'../serve/index.js\')");',
|
||||
'globalThis.eval("import(\'../serve/index.js\')");',
|
||||
'const load = new Function("return import(\'../serve/index.js\')");',
|
||||
]) {
|
||||
await expectServeBoundaryError(ACP_FIXTURE, code);
|
||||
}
|
||||
});
|
||||
|
||||
// file: URLs are "special", so Node's URL-based resolution normalizes
|
||||
// backslashes to '/' — a specifier VALUE containing '\' resolves like
|
||||
// the slash form even on posix.
|
||||
it('rejects backslash-separated serve specifiers', async () => {
|
||||
await expectServeBoundaryError(
|
||||
RUNTIME_FIXTURE,
|
||||
"import '..\\\\serve\\\\index.js';",
|
||||
);
|
||||
});
|
||||
|
||||
// Every getBuiltinModule arm: global.process, destructured bare
|
||||
// identifier, Reflect.apply — plus the computed object-side and
|
||||
// property-side spellings.
|
||||
it('pins every getBuiltinModule spelling', async () => {
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"const mod = global.process.getBuiltinModule('node:module');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"const { getBuiltinModule } = process;\nconst mod = getBuiltinModule('node:module');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"const mod = Reflect.apply(process.getBuiltinModule, null, ['node:module']);",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"const mod = globalThis['process'].getBuiltinModule('module');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"const mod = Reflect.apply(process['getBuiltinModule'], null, ['module']);",
|
||||
);
|
||||
});
|
||||
|
||||
// The root-absolute and file: branches must reach isInServeDir —
|
||||
// 'inside' verdicts (serveBoundary), not just the fail-closed path.
|
||||
it('reports absolute-path and file: imports into serve via serveBoundary', async () => {
|
||||
const serveEntry = `${repoRoot}/packages/cli/src/serve/index.ts`;
|
||||
for (const code of [
|
||||
`import '${serveEntry}';`,
|
||||
`import 'file://${serveEntry}';`,
|
||||
]) {
|
||||
const [result] = await lintCliFile(ACP_FIXTURE, code);
|
||||
expect(
|
||||
result.messages.some(
|
||||
(message) => message.messageId === 'serveBoundary',
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// The child_process.fork MEMBER arm and the template cooked-value
|
||||
// choice each had zero pins (mutants survived).
|
||||
it('pins the fork member arm and template cooked values', async () => {
|
||||
await expectServeBoundaryError(
|
||||
RUNTIME_FIXTURE,
|
||||
"import * as child_process from 'node:child_process';\nchild_process.fork('../serve/index.js');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
RUNTIME_FIXTURE,
|
||||
'export async function load() { await import(`../\\x73erve/index.js`); }',
|
||||
);
|
||||
});
|
||||
|
||||
// fork/Worker arms are object-agnostic: namespace and default-import
|
||||
// spellings must not evade the guard.
|
||||
it('rejects namespace and default-import fork/Worker spellings into serve', async () => {
|
||||
await expectServeBoundaryError(
|
||||
RUNTIME_FIXTURE,
|
||||
"import cp from 'node:child_process';\ncp.fork('../serve/index.js');",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
RUNTIME_FIXTURE,
|
||||
"import wt from 'node:worker_threads';\nnew wt.Worker('../serve/worker.js');",
|
||||
);
|
||||
});
|
||||
|
||||
// Bare destructured vitest loader names (member forms were pinned in
|
||||
// R8-2; bare mock/doMock/importMock had no pin).
|
||||
it('pins bare destructured vitest loader spellings', async () => {
|
||||
for (const name of ['mock', 'doMock', 'importMock']) {
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
`import { ${name} } from 'vitest';\n${name}('../serve/live/live-task-service.js');`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The bare-'module' disjunct had no dynamic-entrance coverage (static
|
||||
// import is intercepted earlier by the ImportDeclaration regex arm).
|
||||
it('fails closed on dynamic bare-module specifiers', async () => {
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"export async function load() { return import('module'); }",
|
||||
);
|
||||
await expectServeBoundaryError(ACP_FIXTURE, "require('module');");
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"export { createRequire } from 'module';",
|
||||
);
|
||||
});
|
||||
|
||||
// new Worker(new URL(spec, import.meta.url)) belongs to the URL arm:
|
||||
// boundary-clean targets produce ZERO diagnostics (no fail-closed on a
|
||||
// fully static construct), serve targets exactly ONE serveBoundary.
|
||||
it('lets the URL arm own new Worker(new URL(spec, import.meta.url))', async () => {
|
||||
await expectNoBoundaryHits(
|
||||
RUNTIME_FIXTURE,
|
||||
"const w = new Worker(new URL('./worker.js', import.meta.url));",
|
||||
);
|
||||
const [result] = await lintCliFile(
|
||||
RUNTIME_FIXTURE,
|
||||
"const w = new Worker(new URL('../serve/worker.js', import.meta.url));",
|
||||
);
|
||||
const hits = result.messages.filter(
|
||||
(message) => message.ruleId === RULE_ID,
|
||||
);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0].messageId).toBe('serveBoundary');
|
||||
});
|
||||
|
||||
// The outside-serve (allow) verdict of the checkSource arms had zero
|
||||
// negative pins — mutating any arm to unconditional fail-closed stayed
|
||||
// green.
|
||||
it('allows URL/Worker/fork/require targets that resolve outside serve', async () => {
|
||||
for (const code of [
|
||||
"const u = new URL('../utils/foo.js', import.meta.url);",
|
||||
"new Worker('../utils/worker.js');",
|
||||
"require('../utils/foo.js');",
|
||||
"import cp from 'node:child_process';\ncp.fork('../utils/foo.js');",
|
||||
]) {
|
||||
await expectNoBoundaryHits(RUNTIME_FIXTURE, code);
|
||||
}
|
||||
});
|
||||
|
||||
// import x = require('../serve/…') — tsc under NodeNext emits a working
|
||||
// createRequire shim, so the spelling loads at runtime.
|
||||
it('rejects import-equals-require into serve', async () => {
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"import x = require('../serve/index.js');",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Round-12 review pins ─────────────────────────────────────────────
|
||||
|
||||
// Symlink canonicalization must be symmetric: the baseUrl arm realpath's
|
||||
// the candidate AND the comparison side is canonicalized, so a
|
||||
// committable symlink inside the baseUrl tree pointing into serve/ is
|
||||
// caught (tsc/esbuild follow it), while a link pointing outside stays
|
||||
// allowed.
|
||||
it('catches baseUrl symlinks that point into serve', async () => {
|
||||
const cliDir = path.join(repoRoot, 'packages/cli');
|
||||
const intoServe = path.join(cliDir, 'serve-alias-fixture');
|
||||
const outOfServe = path.join(cliDir, 'utils-alias-fixture');
|
||||
let created = false;
|
||||
try {
|
||||
symlinkSync(path.join(cliDir, 'src/serve'), intoServe);
|
||||
symlinkSync(path.join(cliDir, 'src/utils'), outOfServe);
|
||||
created = true;
|
||||
} catch {
|
||||
// Platforms without unprivileged symlink support: nothing to pin.
|
||||
}
|
||||
if (!created) return;
|
||||
try {
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"import 'serve-alias-fixture/index.ts';",
|
||||
);
|
||||
await expectNoBoundaryHits(
|
||||
ACP_FIXTURE,
|
||||
"import 'utils-alias-fixture/foo.ts';",
|
||||
);
|
||||
} finally {
|
||||
rmSync(intoServe, { force: true });
|
||||
rmSync(outOfServe, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Callee identity is shape-tolerant: nested member objects, computed
|
||||
// template-literal properties, and renamed bindings must not evade the
|
||||
// loader/fork/eval/getBuiltinModule arms.
|
||||
it('catches shape-variant callee spellings', async () => {
|
||||
for (const code of [
|
||||
// nested member objects evade Identifier-only object checks
|
||||
"globalThis.vi.mock('../serve/live/live-task-service.js');",
|
||||
"x.cp.fork('../serve/index.js');",
|
||||
// expression-free template-literal properties
|
||||
"vi[`mock`]('../serve/live/live-task-service.js');",
|
||||
"cp[`fork`]('../serve/index.js');",
|
||||
'globalThis[`eval`]("import(\'../serve/index.js\')");',
|
||||
"process[`getBuiltinModule`]('module');",
|
||||
"Reflect[`apply`](process.getBuiltinModule, null, ['module']);",
|
||||
]) {
|
||||
await expectServeBoundaryError(ACP_FIXTURE, code);
|
||||
}
|
||||
});
|
||||
|
||||
it('catches renamed loader bindings and Reflect indirection', async () => {
|
||||
for (const code of [
|
||||
"import { Worker as W } from 'node:worker_threads';\nnew W('../serve/worker.js');",
|
||||
"import { fork as f } from 'node:child_process';\nf('../serve/index.js');",
|
||||
"Reflect.construct(Worker, ['../serve/worker.js']);",
|
||||
"Reflect.apply(require, null, ['../serve/index.js']);",
|
||||
"Reflect.apply(fork, null, ['../serve/index.js']);",
|
||||
]) {
|
||||
await expectServeBoundaryError(ACP_FIXTURE, code);
|
||||
}
|
||||
});
|
||||
|
||||
it('catches call/apply/bind indirection on guarded loaders', async () => {
|
||||
for (const code of [
|
||||
"(0, require)('../serve/index.js');",
|
||||
"require.call(null, '../serve/index.js');",
|
||||
"require.apply(null, ['../serve/index.js']);",
|
||||
"fork.bind(null)('../serve/index.js');",
|
||||
"process.getBuiltinModule.call(process, 'node:module');",
|
||||
"process.getBuiltinModule.apply(process, ['node:module']);",
|
||||
]) {
|
||||
await expectServeBoundaryError(ACP_FIXTURE, code);
|
||||
}
|
||||
});
|
||||
|
||||
// The string-code execution class: call-without-new, member spellings,
|
||||
// .constructor chains, the node:vm surface, and Worker's eval option —
|
||||
// all compile/run arbitrary string code that can import() anything.
|
||||
it('fails closed on the string-code execution class', async () => {
|
||||
for (const code of [
|
||||
'const f = Function("return import(\'../serve/index.js\')");',
|
||||
'new globalThis.Function("return import(\'../serve/index.js\')")();',
|
||||
"globalThis.Function('x')();",
|
||||
"Function('x').bind(null)();",
|
||||
'eval.call(null, "import(\'../serve/index.js\')");',
|
||||
'eval.apply(null, ["import(\'../serve/index.js\')"]);',
|
||||
'({}).constructor.constructor("return import(\'../serve/index.js\')")()();',
|
||||
'(function(){}).constructor("return import(\'../serve/index.js\')");',
|
||||
'[].constructor.constructor("return import(\'../serve/index.js\')")()();',
|
||||
"import vm from 'node:vm';\nvm.runInThisContext('x');",
|
||||
"import vm from 'node:vm';\nvm.runInNewContext('x');",
|
||||
"import vm from 'node:vm';\nvm.compileFunction('x');",
|
||||
"import { runInContext } from 'node:vm';\nrunInContext('x', {});",
|
||||
"import vm from 'node:vm';\nnew vm.Script('x');",
|
||||
"new Worker('x', { eval: true });",
|
||||
"new Worker('x', options);",
|
||||
]) {
|
||||
await expectServeBoundaryError(ACP_FIXTURE, code);
|
||||
}
|
||||
// eval: false is statically verifiable — the specifier path applies.
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"new Worker('../serve/worker.js', { eval: false });",
|
||||
);
|
||||
await expectNoBoundaryHits(
|
||||
ACP_FIXTURE,
|
||||
"new Worker('../utils/worker.js', { eval: false });",
|
||||
);
|
||||
// messageId-specific: the eval:true form reports failClosed (arg0 is
|
||||
// code, never a specifier), whatever the first argument looks like.
|
||||
const [evalTrue] = await lintCliFile(
|
||||
ACP_FIXTURE,
|
||||
'new Worker("import(\'../serve/worker.js\')", { eval: true });',
|
||||
);
|
||||
expect(
|
||||
evalTrue.messages.some(
|
||||
(message) =>
|
||||
message.ruleId === RULE_ID && message.messageId === 'failClosed',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// The URL arm resolves only the import.meta.url base; any other
|
||||
// import.meta member is statically unresolvable — fail closed, never
|
||||
// assume the module base.
|
||||
it('fails closed on non-url import.meta bases', async () => {
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"const u = new URL('../serve/index.js', import.meta.resolve);",
|
||||
);
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"const w = new Worker(new URL('../serve/worker.js', import.meta.resolve));",
|
||||
);
|
||||
// messageId-specific: an unresolvable base reports failClosed, not
|
||||
// serveBoundary — the specifier never resolves.
|
||||
for (const code of [
|
||||
"const u = new URL('../serve/index.js', import.meta.env);",
|
||||
"const w = new Worker(new URL('./worker.js', import.meta.env));",
|
||||
]) {
|
||||
const [result] = await lintCliFile(ACP_FIXTURE, code);
|
||||
expect(
|
||||
result.messages.some(
|
||||
(message) =>
|
||||
message.ruleId === RULE_ID && message.messageId === 'failClosed',
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// stripUrlSuffixes must also protect the bare-directory and baseUrl
|
||||
// spellings, not just full-file specifiers.
|
||||
it('strips query/fragment suffixes from bare serve spellings', async () => {
|
||||
await expectServeBoundaryError(RUNTIME_FIXTURE, "import '../serve?foo';");
|
||||
await expectServeBoundaryError(
|
||||
ACP_FIXTURE,
|
||||
"import 'src/serve/index.js?v=1';",
|
||||
);
|
||||
});
|
||||
|
||||
// The outside-serve (allow) verdict needs pins for the export and
|
||||
// import-equals arms too — otherwise mutating them to unconditional
|
||||
// fail-closed stays green.
|
||||
it('allows exports and import-equals that resolve outside serve', async () => {
|
||||
for (const code of [
|
||||
"export * from '../utils/foo.js';",
|
||||
"export { x } from '../utils/foo.js';",
|
||||
"import x = require('../utils/foo.js');",
|
||||
]) {
|
||||
await expectNoBoundaryHits(ACP_FIXTURE, code);
|
||||
}
|
||||
});
|
||||
});
|
||||
<EFBFBD>٩<EFBFBD><EFBFBD>hof<EFBFBD>
|
||||
Loading…
Add table
Add a link
Reference in a new issue