mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:41:37 +00:00
* fix: harden plugin import-boundary enforcement * fix: exclude generated bundles from plugin source guards * fix: make plugin boundary scanner linear and complete
365 lines
12 KiB
JavaScript
365 lines
12 KiB
JavaScript
// Shared parsing, diffing, and reporting helpers for inventory guard scripts.
|
|
import { promises as fs } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
|
|
const parsedTypeScriptSourceCache = new Map();
|
|
const sourceTextCache = new Map();
|
|
const require = createRequire(import.meta.url);
|
|
let cachedTypeScript;
|
|
|
|
/** Convert an absolute file path to a repo-relative POSIX path. */
|
|
export function normalizeRepoPath(repoRoot, filePath) {
|
|
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
|
}
|
|
|
|
/** Resolve a relative or absolute module specifier to a repo-relative path. */
|
|
export function resolveRepoSpecifier(repoRoot, specifier, importerFile) {
|
|
if (specifier.startsWith(".")) {
|
|
return normalizeRepoPath(repoRoot, path.resolve(path.dirname(importerFile), specifier));
|
|
}
|
|
if (specifier.startsWith("/")) {
|
|
return normalizeRepoPath(repoRoot, specifier);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Visit static and dynamic module specifiers in a parsed TypeScript source file. */
|
|
export function visitModuleSpecifiers(ts, sourceFile, visit, options = {}) {
|
|
function walk(node) {
|
|
let kind;
|
|
let specifierNode;
|
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
kind = "import";
|
|
specifierNode = node.moduleSpecifier;
|
|
} else if (
|
|
ts.isExportDeclaration(node) &&
|
|
node.moduleSpecifier &&
|
|
ts.isStringLiteral(node.moduleSpecifier)
|
|
) {
|
|
kind = "export";
|
|
specifierNode = node.moduleSpecifier;
|
|
} else if (
|
|
ts.isCallExpression(node) &&
|
|
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
node.arguments.length >= 1 &&
|
|
ts.isStringLiteralLike(node.arguments[0])
|
|
) {
|
|
kind = "dynamic-import";
|
|
specifierNode = node.arguments[0];
|
|
} else if (
|
|
options.includeImportTypes &&
|
|
ts.isImportTypeNode(node) &&
|
|
ts.isLiteralTypeNode(node.argument) &&
|
|
ts.isStringLiteralLike(node.argument.literal)
|
|
) {
|
|
kind = "dynamic-import";
|
|
specifierNode = node.argument.literal;
|
|
} else if (
|
|
options.includeCommonJs &&
|
|
ts.isCallExpression(node) &&
|
|
ts.isIdentifier(node.expression) &&
|
|
node.expression.text === "require" &&
|
|
node.arguments.length >= 1 &&
|
|
ts.isStringLiteralLike(node.arguments[0])
|
|
) {
|
|
kind = "commonjs-require";
|
|
specifierNode = node.arguments[0];
|
|
} else if (
|
|
options.includeCommonJs &&
|
|
ts.isImportEqualsDeclaration(node) &&
|
|
ts.isExternalModuleReference(node.moduleReference) &&
|
|
node.moduleReference.expression &&
|
|
ts.isStringLiteralLike(node.moduleReference.expression)
|
|
) {
|
|
kind = "commonjs-require";
|
|
specifierNode = node.moduleReference.expression;
|
|
} else if (
|
|
options.includeImportMetaUrl &&
|
|
ts.isNewExpression(node) &&
|
|
ts.isIdentifier(node.expression) &&
|
|
node.expression.text === "URL" &&
|
|
node.arguments?.length >= 2 &&
|
|
ts.isStringLiteralLike(node.arguments[0]) &&
|
|
ts.isPropertyAccessExpression(node.arguments[1]) &&
|
|
node.arguments[1].name.text === "url" &&
|
|
ts.isMetaProperty(node.arguments[1].expression) &&
|
|
node.arguments[1].expression.keywordToken === ts.SyntaxKind.ImportKeyword &&
|
|
node.arguments[1].expression.name.text === "meta"
|
|
) {
|
|
kind = "import-meta-url";
|
|
specifierNode = node.arguments[0];
|
|
}
|
|
|
|
if (specifierNode) {
|
|
visit({ kind, node, specifier: specifierNode.text, specifierNode });
|
|
}
|
|
ts.forEachChild(node, walk);
|
|
}
|
|
|
|
walk(sourceFile);
|
|
}
|
|
|
|
/** Diff expected and actual inventory entries using JSON identity. */
|
|
export function diffInventoryEntries(expected, actual, compareEntries) {
|
|
const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry)));
|
|
const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry)));
|
|
return {
|
|
missing: expected
|
|
.filter((entry) => !actualKeys.has(JSON.stringify(entry)))
|
|
.toSorted(compareEntries),
|
|
unexpected: actual
|
|
.filter((entry) => !expectedKeys.has(JSON.stringify(entry)))
|
|
.toSorted(compareEntries),
|
|
};
|
|
}
|
|
|
|
/** Write one line to a stream without each caller repeating newline handling. */
|
|
export function writeLine(stream, text) {
|
|
stream.write(`${text}\n`);
|
|
}
|
|
|
|
function hasSupplementalModuleReference(ts, source, acceptSpecifier) {
|
|
const checkUrl = source.includes("new") && source.includes("import") && source.includes("meta");
|
|
const interpolationStart = source.indexOf("${");
|
|
const checkRequire =
|
|
interpolationStart >= 0 &&
|
|
(source.includes("require") || source.indexOf("\\u", interpolationStart + 2) >= 0);
|
|
const interpolatedSlash =
|
|
interpolationStart < 0 ? -1 : source.indexOf("/", interpolationStart + 2);
|
|
const checkTemplateImport =
|
|
interpolatedSlash >= 0 && source.indexOf("import", interpolatedSlash + 1) >= 0;
|
|
const checkNamespaceExport =
|
|
source.includes("export") && source.includes("*") && source.includes("as");
|
|
if (!checkUrl && !checkRequire && !checkTemplateImport && !checkNamespaceExport) {
|
|
return false;
|
|
}
|
|
|
|
const scanner = ts.createScanner(
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
ts.LanguageVariant.Standard,
|
|
source,
|
|
);
|
|
const kinds = ts.SyntaxKind;
|
|
const templateBraceDepths = [];
|
|
const scanAcceptedSpecifier = () => {
|
|
const token = scanner.scan();
|
|
return (
|
|
(token === kinds.StringLiteral || token === kinds.NoSubstitutionTemplateLiteral) &&
|
|
acceptSpecifier(scanner.getTokenValue())
|
|
);
|
|
};
|
|
|
|
for (let token = scanner.scan(); token !== kinds.EndOfFileToken; token = scanner.scan()) {
|
|
if (
|
|
checkUrl &&
|
|
token === kinds.NewKeyword &&
|
|
scanner.lookAhead(
|
|
() =>
|
|
scanner.scan() === kinds.Identifier &&
|
|
scanner.getTokenValue() === "URL" &&
|
|
scanner.scan() === kinds.OpenParenToken &&
|
|
scanAcceptedSpecifier(),
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
checkRequire &&
|
|
(token === kinds.RequireKeyword ||
|
|
(token === kinds.Identifier && scanner.getTokenValue() === "require")) &&
|
|
scanner.lookAhead(() => scanner.scan() === kinds.OpenParenToken && scanAcceptedSpecifier())
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
checkNamespaceExport &&
|
|
token === kinds.ExportKeyword &&
|
|
scanner.lookAhead(() => {
|
|
let next = scanner.scan();
|
|
if (next === kinds.TypeKeyword) {
|
|
next = scanner.scan();
|
|
}
|
|
return next === kinds.AsteriskToken && scanner.scan() === kinds.AsKeyword;
|
|
})
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// A context-free slash may start a regexp whose `}` would corrupt interpolation tracking.
|
|
if (
|
|
templateBraceDepths.length > 0 &&
|
|
(token === kinds.SlashToken || token === kinds.SlashEqualsToken)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Resume after each nested interpolation; otherwise template tails swallow later imports.
|
|
if (token === kinds.TemplateHead) {
|
|
templateBraceDepths.push(0);
|
|
} else if (token === kinds.OpenBraceToken && templateBraceDepths.length > 0) {
|
|
templateBraceDepths[templateBraceDepths.length - 1] += 1;
|
|
} else if (token === kinds.CloseBraceToken && templateBraceDepths.length > 0) {
|
|
const index = templateBraceDepths.length - 1;
|
|
if (templateBraceDepths[index] > 0) {
|
|
templateBraceDepths[index] -= 1;
|
|
} else if (scanner.reScanTemplateToken(false) === kinds.TemplateTail) {
|
|
templateBraceDepths.pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** Lexically reject clean files before parsing candidate module-boundary violations. */
|
|
export function collectModuleReferencesFromSource(source, options = {}) {
|
|
const ts = options.ts ?? (cachedTypeScript ??= require("typescript"));
|
|
const acceptSpecifier = options.acceptSpecifier ?? (() => true);
|
|
const candidates = ts.preProcessFile(source, true, true).importedFiles;
|
|
if (
|
|
!candidates.some(({ fileName }) => acceptSpecifier(fileName)) &&
|
|
!hasSupplementalModuleReference(ts, source, acceptSpecifier)
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const sourceFile = ts.createSourceFile(
|
|
options.fileName ?? "source.ts",
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
);
|
|
const references = [];
|
|
visitModuleSpecifiers(
|
|
ts,
|
|
sourceFile,
|
|
({ kind, specifier, specifierNode }) => {
|
|
if (acceptSpecifier(specifier)) {
|
|
references.push({
|
|
kind,
|
|
line:
|
|
sourceFile.getLineAndCharacterOfPosition(specifierNode.getStart(sourceFile)).line + 1,
|
|
specifier,
|
|
});
|
|
}
|
|
},
|
|
{ includeCommonJs: true, includeImportMetaUrl: true, includeImportTypes: true },
|
|
);
|
|
|
|
return references.toSorted(
|
|
(left, right) =>
|
|
left.line - right.line ||
|
|
left.kind.localeCompare(right.kind) ||
|
|
left.specifier.localeCompare(right.specifier),
|
|
);
|
|
}
|
|
|
|
/** Memoize an async factory while resetting the cache after failures. */
|
|
export function createCachedAsync(factory) {
|
|
let cachedPromise = null;
|
|
return async function getCachedValue() {
|
|
if (cachedPromise) {
|
|
return cachedPromise;
|
|
}
|
|
|
|
cachedPromise = factory();
|
|
try {
|
|
return await cachedPromise;
|
|
} catch (error) {
|
|
cachedPromise = null;
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Format grouped inventory entries for human-readable guard output. */
|
|
export function formatGroupedInventoryHuman(params, inventory) {
|
|
if (inventory.length === 0) {
|
|
return `${params.rule}\n${params.cleanMessage}`;
|
|
}
|
|
|
|
const lines = [params.rule, params.inventoryTitle];
|
|
let activeFile = "";
|
|
for (const entry of inventory) {
|
|
if (entry.file !== activeFile) {
|
|
activeFile = entry.file;
|
|
lines.push(activeFile);
|
|
}
|
|
lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`);
|
|
lines.push(` specifier: ${entry.specifier}`);
|
|
lines.push(` resolved: ${entry.resolvedPath}`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/** Parse TypeScript files and collect sorted inventory entries from each source file. */
|
|
export async function collectTypeScriptInventory(params) {
|
|
const inventory = [];
|
|
|
|
for (const filePath of params.files) {
|
|
const cacheKey = `${params.scriptKind ?? "auto"}:${filePath}`;
|
|
let sourceFile = parsedTypeScriptSourceCache.get(cacheKey);
|
|
if (!sourceFile) {
|
|
let source = sourceTextCache.get(filePath);
|
|
if (source === undefined) {
|
|
source = await fs.readFile(filePath, "utf8");
|
|
sourceTextCache.set(filePath, source);
|
|
}
|
|
if (params.shouldParseSource && !params.shouldParseSource(source, filePath)) {
|
|
continue;
|
|
}
|
|
sourceFile = params.ts.createSourceFile(
|
|
filePath,
|
|
source,
|
|
params.ts.ScriptTarget.Latest,
|
|
true,
|
|
params.scriptKind,
|
|
);
|
|
parsedTypeScriptSourceCache.set(cacheKey, sourceFile);
|
|
}
|
|
inventory.push(...params.collectEntries(sourceFile, filePath));
|
|
}
|
|
|
|
return inventory.toSorted(params.compareEntries);
|
|
}
|
|
|
|
/** Run a baseline inventory check and return the intended process exit code. */
|
|
export async function runBaselineInventoryCheck(params) {
|
|
const streams = params.io ?? { stdout: process.stdout, stderr: process.stderr };
|
|
const json = params.argv.includes("--json");
|
|
const actual = await params.collectActual();
|
|
const expected = await params.readExpected();
|
|
const { missing, unexpected } = params.diffInventory(expected, actual);
|
|
const matchesBaseline = missing.length === 0 && unexpected.length === 0;
|
|
|
|
if (json) {
|
|
writeLine(streams.stdout, JSON.stringify(actual, null, 2));
|
|
} else {
|
|
writeLine(streams.stdout, params.formatInventoryHuman(actual));
|
|
writeLine(
|
|
streams.stdout,
|
|
matchesBaseline
|
|
? `Baseline matches (${actual.length} entries).`
|
|
: `Baseline mismatch (${unexpected.length} unexpected, ${missing.length} missing).`,
|
|
);
|
|
if (!matchesBaseline) {
|
|
if (unexpected.length > 0) {
|
|
writeLine(streams.stderr, "Unexpected entries:");
|
|
for (const entry of unexpected) {
|
|
writeLine(streams.stderr, `- ${params.formatEntry(entry)}`);
|
|
}
|
|
}
|
|
if (missing.length > 0) {
|
|
writeLine(streams.stderr, "Missing baseline entries:");
|
|
for (const entry of missing) {
|
|
writeLine(streams.stderr, `- ${params.formatEntry(entry)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return matchesBaseline ? 0 : 1;
|
|
}
|