497 lines
27 KiB
JavaScript
Executable File
497 lines
27 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const vm = require('vm');
|
||
const { loadNativeAddon } = require('./load_native_addon');
|
||
|
||
function parseArgs(argv) {
|
||
const args = { target: '', entry: '', fixture: '', trace: '', summary: '', output: '', addon: '', timeout: 5000, json: false, markdown: false };
|
||
for (let i = 2; i < argv.length; i++) {
|
||
const a = argv[i];
|
||
if (a === '--target') args.target = argv[++i] || '';
|
||
else if (a === '--entry') args.entry = argv[++i] || '';
|
||
else if (a === '--fixture') args.fixture = argv[++i] || '';
|
||
else if (a === '--trace') args.trace = argv[++i] || '';
|
||
else if (a === '--summary') args.summary = argv[++i] || '';
|
||
else if (a === '--output') args.output = argv[++i] || '';
|
||
else if (a === '--addon') args.addon = argv[++i] || '';
|
||
else if (a === '--timeout') args.timeout = Number(argv[++i] || '5000');
|
||
else if (a === '--json') args.json = true;
|
||
else if (a === '--markdown') args.markdown = true;
|
||
else if (a === '--help' || a === '-h') args.help = true;
|
||
else throw new Error(`未知参数:${a}`);
|
||
}
|
||
if (!args.json && !args.markdown) args.markdown = true;
|
||
return args;
|
||
}
|
||
|
||
function usage() {
|
||
return `用法:
|
||
node scripts/run_with_trace.js --target case/js/original/app.js --entry window.makeSign --fixture case/fixtures/sample.fixture.json --trace case/tmp/env-trace.jsonl --summary case/tmp/missing-env.json --output case/tmp/node-output.json
|
||
|
||
说明:该脚本用于探测模式。默认在 vm 探测上下文内定义浏览器桩函数,避免把宿主 require/process/Buffer 以及 Node 自带 navigator/performance/localStorage 等 Web API 兼容层暴露给目标 JS;正式交付不强制只能使用 vm。`;
|
||
}
|
||
|
||
function readJson(p) { return p ? JSON.parse(fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, '')) : {}; }
|
||
function ensureParent(p) { if (p) fs.mkdirSync(path.dirname(path.resolve(p)), { recursive: true }); }
|
||
|
||
function bootstrapSource(fixture) {
|
||
const fixtureText = JSON.stringify(JSON.stringify(fixture || {}));
|
||
return `
|
||
'use strict';
|
||
const __fixture = JSON.parse(${fixtureText});
|
||
Object.defineProperty(globalThis, '__events', { value: [], enumerable: false, configurable: true });
|
||
function __safe(v) {
|
||
if (v == null || ['string', 'number', 'boolean'].includes(typeof v)) return v;
|
||
if (Array.isArray(v)) return v.slice(0, 20).map(__safe);
|
||
if (typeof v === 'function') return '[Function ' + (v.name || 'anonymous') + ']';
|
||
if (typeof v === 'object') return '[Object ' + ((v.constructor && v.constructor.name) || 'Object') + ']';
|
||
return '[' + typeof v + ']';
|
||
}
|
||
function __push(e) {
|
||
try {
|
||
e.ts = Date.now();
|
||
__events.push(e);
|
||
} catch (_) {}
|
||
}
|
||
Object.defineProperty(globalThis, '__push', { value: __push, enumerable: false, configurable: true });
|
||
|
||
function traceProxy(name, options) {
|
||
options = options || {};
|
||
const target = function traceProxyFunction() {};
|
||
const cache = Object.create(null);
|
||
const primitive = options.primitive == null ? '' : options.primitive;
|
||
return new Proxy(target, {
|
||
get(t, prop) {
|
||
if (prop === Symbol.toPrimitive) return function(hint) { __push({ type: 'toPrimitive', path: name, hint: hint }); return hint === 'number' ? 0 : primitive; };
|
||
if (prop === Symbol.toStringTag) return options.tag || name.split('.').pop() || 'Object';
|
||
if (prop === 'then') return undefined;
|
||
if (prop === 'toString') return function toString() { __push({ type: 'call', path: name + '.toString', args: [] }); return String(primitive || '[object ' + (options.tag || 'Object') + ']'); };
|
||
if (prop === 'valueOf') return function valueOf() { __push({ type: 'call', path: name + '.valueOf', args: [] }); return primitive || 0; };
|
||
const key = String(prop);
|
||
__push({ type: 'get', path: name + '.' + key });
|
||
if (!cache[key]) cache[key] = traceProxy(name + '.' + key);
|
||
return cache[key];
|
||
},
|
||
set(t, prop, value) { const key = String(prop); __push({ type: 'set', path: name + '.' + key, value: __safe(value), valueType: typeof value }); cache[key] = value; return true; },
|
||
has(t, prop) { __push({ type: 'has', path: name, prop: String(prop) }); return Object.prototype.hasOwnProperty.call(cache, String(prop)); },
|
||
ownKeys() { __push({ type: 'ownKeys', path: name }); return Object.keys(cache); },
|
||
getOwnPropertyDescriptor(t, prop) { __push({ type: 'getOwnPropertyDescriptor', path: name, prop: String(prop) }); return cache[String(prop)] ? { value: cache[String(prop)], enumerable: true, configurable: true, writable: true } : undefined; },
|
||
getPrototypeOf() { __push({ type: 'getPrototypeOf', path: name }); return Function.prototype; },
|
||
apply(t, thisArg, args) { __push({ type: 'call', path: name, args: Array.from(args).map(v => typeof v) }); return traceProxy(name + '()'); },
|
||
construct(t, args) { __push({ type: 'new', path: name, args: Array.from(args).map(v => typeof v) }); return traceProxy('new ' + name); },
|
||
});
|
||
}
|
||
|
||
function makeStorage(name, initial) {
|
||
const data = new Map(Object.entries(initial || {}).map(([k, v]) => [String(k), String(v)]));
|
||
const storage = {
|
||
get length() { __push({ type: 'get', path: name + '.length' }); return data.size; },
|
||
key(i) { __push({ type: 'call', path: name + '.key', args: ['number'] }); return Array.from(data.keys())[Number(i)] ?? null; },
|
||
getItem(k) { __push({ type: 'call', path: name + '.getItem', args: ['string'] }); k = String(k); return data.has(k) ? data.get(k) : null; },
|
||
setItem(k, v) { __push({ type: 'call', path: name + '.setItem', args: ['string', 'string'] }); data.set(String(k), String(v)); },
|
||
removeItem(k) { __push({ type: 'call', path: name + '.removeItem', args: ['string'] }); data.delete(String(k)); },
|
||
clear() { __push({ type: 'call', path: name + '.clear', args: [] }); data.clear(); },
|
||
};
|
||
Object.defineProperty(storage, '__data', { value: data, enumerable: false });
|
||
return storage;
|
||
}
|
||
|
||
function parseLocation(raw) {
|
||
const s = String(raw || 'https://example.com/');
|
||
const m = s.match(/^([a-z][a-z0-9+.-]*:)\\/\\/([^\\/?#]*)([^?#]*)(\\?[^#]*)?(#.*)?$/i);
|
||
if (!m) return { href: s, origin: '', protocol: '', host: '', hostname: '', port: '', pathname: '', search: '', hash: '', toString() { return this.href; } };
|
||
const protocol = m[1], host = m[2], pathname = m[3] || '/', search = m[4] || '', hash = m[5] || '';
|
||
const hp = host.split(':');
|
||
return { href: protocol + '//' + host + pathname + search + hash, origin: protocol + '//' + host, protocol, host, hostname: hp[0], port: hp[1] || '', pathname, search, hash, toString() { return this.href; } };
|
||
}
|
||
|
||
class URLSearchParams {
|
||
constructor(init) {
|
||
this._pairs = [];
|
||
if (typeof init === 'string') {
|
||
const text = init.replace(/^\\?/, '');
|
||
if (text) for (const part of text.split('&')) {
|
||
if (!part) continue;
|
||
const idx = part.indexOf('=');
|
||
const k = idx >= 0 ? part.slice(0, idx) : part;
|
||
const v = idx >= 0 ? part.slice(idx + 1) : '';
|
||
this.append(decodeURIComponent(k.replace(/\\+/g, ' ')), decodeURIComponent(v.replace(/\\+/g, ' ')));
|
||
}
|
||
} else if (init && typeof init === 'object') {
|
||
for (const k of Object.keys(init)) this.append(k, init[k]);
|
||
}
|
||
}
|
||
append(k, v) { this._pairs.push([String(k), String(v)]); }
|
||
set(k, v) { k = String(k); this._pairs = this._pairs.filter(p => p[0] !== k); this.append(k, v); }
|
||
get(k) { k = String(k); const p = this._pairs.find(x => x[0] === k); return p ? p[1] : null; }
|
||
has(k) { k = String(k); return this._pairs.some(x => x[0] === k); }
|
||
toString() { return this._pairs.map(p => encodeURIComponent(p[0]) + '=' + encodeURIComponent(p[1])).join('&'); }
|
||
}
|
||
globalThis.URLSearchParams = URLSearchParams;
|
||
|
||
class URL {
|
||
constructor(input, base) {
|
||
const loc = parseLocation(input || base || 'https://example.com/');
|
||
Object.assign(this, loc);
|
||
this.searchParams = new URLSearchParams(this.search);
|
||
}
|
||
toString() { return this.href; }
|
||
}
|
||
globalThis.URL = URL;
|
||
|
||
class TextEncoder {
|
||
encode(input) {
|
||
const s = unescape(encodeURIComponent(String(input)));
|
||
const out = new Uint8Array(s.length);
|
||
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
||
return out;
|
||
}
|
||
}
|
||
class TextDecoder {
|
||
decode(input) {
|
||
const arr = Array.from(input || []);
|
||
return decodeURIComponent(escape(String.fromCharCode.apply(null, arr)));
|
||
}
|
||
}
|
||
globalThis.TextEncoder = TextEncoder;
|
||
globalThis.TextDecoder = TextDecoder;
|
||
|
||
const __b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||
function btoa(input) {
|
||
let str = String(input), output = '';
|
||
for (let block = 0, charCode, i = 0, map = __b64chars; str.charAt(i | 0) || (map = '=', i % 1); output += map.charAt(63 & block >> 8 - i % 1 * 8)) {
|
||
charCode = str.charCodeAt(i += 3 / 4);
|
||
if (charCode > 0xFF) throw new Error('字符超出 Latin1 范围');
|
||
block = block << 8 | charCode;
|
||
}
|
||
return output;
|
||
}
|
||
function atob(input) {
|
||
let str = String(input).replace(/=+$/, ''), output = '';
|
||
if (str.length % 4 === 1) throw new Error('无效 base64');
|
||
for (let bc = 0, bs = 0, buffer, i = 0; buffer = str.charAt(i++); ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0) {
|
||
buffer = __b64chars.indexOf(buffer);
|
||
}
|
||
return output;
|
||
}
|
||
globalThis.atob = atob;
|
||
globalThis.btoa = btoa;
|
||
|
||
const console = {};
|
||
for (const name of ['log', 'warn', 'error', 'info', 'debug', 'trace', 'group', 'groupEnd']) {
|
||
console[name] = function() { __push({ type: 'console', path: 'console.' + name, args: Array.from(arguments).map(v => typeof v) }); };
|
||
}
|
||
globalThis.console = console;
|
||
|
||
let __timerId = 1;
|
||
globalThis.setTimeout = function(fn, delay) { __push({ type: 'call', path: 'setTimeout', args: [typeof fn, typeof delay] }); return __timerId++; };
|
||
globalThis.clearTimeout = function(id) { __push({ type: 'call', path: 'clearTimeout', args: [typeof id] }); };
|
||
globalThis.setInterval = function(fn, delay) { __push({ type: 'call', path: 'setInterval', args: [typeof fn, typeof delay] }); return __timerId++; };
|
||
globalThis.clearInterval = function(id) { __push({ type: 'call', path: 'clearInterval', args: [typeof id] }); };
|
||
|
||
globalThis.window = globalThis;
|
||
globalThis.self = globalThis;
|
||
globalThis.top = globalThis;
|
||
globalThis.parent = globalThis;
|
||
|
||
const browser = __fixture.browser || {};
|
||
const request = __fixture.request || {};
|
||
const runtime = __fixture.runtime || {};
|
||
globalThis.location = parseLocation(__fixture.pageUrl || __fixture.url || __fixture.apiUrl || 'https://example.com/');
|
||
globalThis.screen = Object.assign({ width: 1920, height: 1080, availWidth: 1920, availHeight: 1040, colorDepth: 24, pixelDepth: 24 }, browser.screen || {});
|
||
globalThis.devicePixelRatio = browser.devicePixelRatio || 1;
|
||
globalThis.history = traceProxy('history');
|
||
const __navigatorData = {
|
||
userAgent: browser.userAgent || '',
|
||
language: browser.language || 'zh-CN',
|
||
languages: browser.languages || [browser.language || 'zh-CN', 'zh'],
|
||
platform: browser.platform || 'Win32',
|
||
hardwareConcurrency: browser.hardwareConcurrency || 8,
|
||
deviceMemory: browser.deviceMemory || 8,
|
||
webdriver: false,
|
||
plugins: traceProxy('navigator.plugins', { tag: 'PluginArray' }),
|
||
mimeTypes: traceProxy('navigator.mimeTypes', { tag: 'MimeTypeArray' }),
|
||
};
|
||
globalThis.navigator = {};
|
||
for (const key of Object.keys(__navigatorData)) {
|
||
Object.defineProperty(globalThis.navigator, key, {
|
||
get() { __push({ type: 'get', path: 'navigator.' + key }); return __navigatorData[key]; },
|
||
enumerable: true,
|
||
configurable: true,
|
||
});
|
||
}
|
||
|
||
class Document {}
|
||
class HTMLDocument extends Document {}
|
||
globalThis.Document = Document;
|
||
globalThis.HTMLDocument = HTMLDocument;
|
||
const document = new HTMLDocument();
|
||
let cookieText = request.cookies && typeof request.cookies === 'object' ? Object.entries(request.cookies).map(([k, v]) => k + '=' + v).join('; ') : '';
|
||
Object.defineProperty(Document.prototype, 'cookie', {
|
||
get() { __push({ type: 'get', path: 'document.cookie' }); return cookieText; },
|
||
set(v) { __push({ type: 'set', path: 'document.cookie', valueType: typeof v }); cookieText = cookieText ? cookieText + '; ' + String(v).split(';')[0] : String(v).split(';')[0]; },
|
||
enumerable: true,
|
||
configurable: true,
|
||
});
|
||
document.URL = globalThis.location.href;
|
||
document.referrer = __fixture.referrer || '';
|
||
document.createElement = function(tag) { __push({ type: 'call', path: 'document.createElement', args: ['string'] }); return traceProxy('document.createElement(' + String(tag) + ')', { tag: String(tag).toUpperCase() }); };
|
||
document.querySelector = function() { __push({ type: 'call', path: 'document.querySelector', args: ['string'] }); return null; };
|
||
document.querySelectorAll = function() { __push({ type: 'call', path: 'document.querySelectorAll', args: ['string'] }); return []; };
|
||
Object.defineProperty(document, 'all', { get() { __push({ type: 'get', path: 'document.all' }); return undefined; }, enumerable: false, configurable: true });
|
||
globalThis.document = document;
|
||
|
||
globalThis.localStorage = makeStorage('localStorage', request.localStorage || __fixture.localStorage || {});
|
||
globalThis.sessionStorage = makeStorage('sessionStorage', request.sessionStorage || __fixture.sessionStorage || {});
|
||
|
||
const randomBytes = Array.isArray(runtime.randomBytes) ? runtime.randomBytes : [];
|
||
globalThis.crypto = {
|
||
getRandomValues(arr) { __push({ type: 'call', path: 'crypto.getRandomValues', args: [arr && arr.constructor && arr.constructor.name || typeof arr] }); for (let i = 0; i < arr.length; i++) arr[i] = randomBytes[i] ?? 0; return arr; },
|
||
randomUUID() { __push({ type: 'call', path: 'crypto.randomUUID', args: [] }); return runtime.randomUUID || '00000000-0000-4000-8000-000000000000'; },
|
||
subtle: traceProxy('crypto.subtle'),
|
||
};
|
||
globalThis.performance = {
|
||
now() { __push({ type: 'call', path: 'performance.now', args: [] }); return runtime.performanceNow ?? 0; },
|
||
timeOrigin: runtime.timeOrigin ?? runtime.now ?? Date.now(),
|
||
};
|
||
globalThis.fetch = function fetchStub() { __push({ type: 'call', path: 'fetch', args: Array.from(arguments).map(v => typeof v) }); return Promise.reject(new Error('补环境探测模式默认不发起真实网络请求')); };
|
||
for (const name of ['XMLHttpRequest', 'Image', 'HTMLElement', 'HTMLCanvasElement', 'WebGLRenderingContext', 'Headers', 'Request', 'Response', 'WebSocket', 'Worker', 'MessageChannel']) {
|
||
globalThis[name] = traceProxy(name);
|
||
}
|
||
|
||
if (runtime.now) {
|
||
const RealDate = Date;
|
||
function FixedDate() {
|
||
const args = Array.from(arguments);
|
||
if (!(this instanceof FixedDate)) return new RealDate(args.length ? args[0] : runtime.now).toString();
|
||
return new RealDate(args.length ? args[0] : runtime.now);
|
||
}
|
||
FixedDate.now = function() { return runtime.now; };
|
||
FixedDate.parse = RealDate.parse;
|
||
FixedDate.UTC = RealDate.UTC;
|
||
FixedDate.prototype = RealDate.prototype;
|
||
globalThis.Date = FixedDate;
|
||
}
|
||
|
||
function __resolveEntry(entry) {
|
||
const parts = String(entry || '').split('.').filter(Boolean);
|
||
if (parts[0] === 'globalThis' || parts[0] === 'window') parts.shift();
|
||
let cur = globalThis;
|
||
for (const part of parts) { if (cur == null) return undefined; cur = cur[part]; }
|
||
return cur;
|
||
}
|
||
function __serialize(v) {
|
||
if (v == null || ['string', 'number', 'boolean'].includes(typeof v)) return v;
|
||
if (typeof v === 'bigint') return String(v);
|
||
if (typeof v === 'function') return '[Function ' + (v.name || 'anonymous') + ']';
|
||
try { return JSON.parse(JSON.stringify(v)); } catch (_) { return String(v); }
|
||
}
|
||
Object.defineProperty(globalThis, '__callEntry', { value: function(entry, argsJson) {
|
||
const fn = __resolveEntry(entry);
|
||
if (typeof fn !== 'function') return JSON.stringify({ entryFound: false, output: undefined, error: { name: 'EntryError', message: '入口不是函数或不存在:' + entry } });
|
||
try {
|
||
const args = JSON.parse(argsJson || '[]');
|
||
const ret = fn.apply(globalThis, Array.isArray(args) ? args : []);
|
||
if (ret && typeof ret.then === 'function') return JSON.stringify({ entryFound: true, output: '[Promise]', async: true });
|
||
return JSON.stringify({ entryFound: true, output: __serialize(ret), outputType: typeof ret });
|
||
} catch (err) {
|
||
return JSON.stringify({ entryFound: true, output: undefined, error: { name: err && err.name || 'Error', message: err && err.message || String(err) } });
|
||
}
|
||
}, enumerable: false, configurable: true });
|
||
Object.defineProperty(globalThis, '__dumpEvents', { value: function() { return JSON.stringify(__events); }, enumerable: false, configurable: true });
|
||
Object.defineProperty(globalThis, '__leakageCheck', { value: function() {
|
||
let functionProcess = 'error';
|
||
let objectCtorProcess = 'error';
|
||
let arrayCtorProcess = 'error';
|
||
try { functionProcess = Function('return typeof process')(); } catch (err) { functionProcess = 'error:' + err.message; }
|
||
try { objectCtorProcess = ({}).constructor.constructor('return typeof process')(); } catch (err) { objectCtorProcess = 'error:' + err.message; }
|
||
try { arrayCtorProcess = ([]).constructor.constructor('return typeof process')(); } catch (err) { arrayCtorProcess = 'error:' + err.message; }
|
||
const navUserAgent = typeof navigator !== 'undefined' && navigator ? String(navigator.userAgent || '') : 'undefined';
|
||
const perfHas = function(name) { try { return typeof performance !== 'undefined' && performance ? (name in performance) : false; } catch (_) { return false; } };
|
||
return JSON.stringify({
|
||
process: typeof process,
|
||
Buffer: typeof Buffer,
|
||
require: typeof require,
|
||
module: typeof module,
|
||
exports: typeof exports,
|
||
global: typeof global,
|
||
setImmediate: typeof setImmediate,
|
||
clearImmediate: typeof clearImmediate,
|
||
functionProcess,
|
||
objectCtorProcess,
|
||
arrayCtorProcess,
|
||
navigator: typeof navigator,
|
||
navigatorUserAgent: navUserAgent,
|
||
navigatorIsNodeUserAgent: /^Node\\.js\\//.test(navUserAgent),
|
||
navigatorLocks: typeof navigator !== 'undefined' && navigator ? typeof navigator.locks : 'undefined',
|
||
performance: typeof performance,
|
||
performanceNodeTiming: perfHas('nodeTiming') ? 'present' : 'absent',
|
||
performanceEventLoopUtilization: perfHas('eventLoopUtilization') ? 'present' : 'absent',
|
||
performanceTimerify: perfHas('timerify') ? 'present' : 'absent',
|
||
performanceMarkResourceTiming: perfHas('markResourceTiming') ? 'present' : 'absent',
|
||
localStorage: typeof localStorage,
|
||
localStorageConstructor: typeof localStorage !== 'undefined' && localStorage && localStorage.constructor ? localStorage.constructor.name : 'undefined',
|
||
sessionStorage: typeof sessionStorage,
|
||
sessionStorageConstructor: typeof sessionStorage !== 'undefined' && sessionStorage && sessionStorage.constructor ? sessionStorage.constructor.name : 'undefined',
|
||
fetch: typeof fetch,
|
||
webSocket: typeof WebSocket,
|
||
broadcastChannel: typeof BroadcastChannel,
|
||
messageChannel: typeof MessageChannel,
|
||
crypto: typeof crypto,
|
||
cryptoConstructor: typeof crypto !== 'undefined' && crypto && crypto.constructor ? crypto.constructor.name : 'undefined',
|
||
subtleCrypto: typeof crypto !== 'undefined' && crypto && crypto.subtle ? typeof crypto.subtle : 'undefined',
|
||
URL: typeof URL,
|
||
URLConstructor: typeof URL !== 'undefined' && URL ? URL.name : 'undefined',
|
||
URLSearchParams: typeof URLSearchParams,
|
||
TextEncoder: typeof TextEncoder,
|
||
TextDecoder: typeof TextDecoder,
|
||
ReadableStream: typeof ReadableStream,
|
||
EventTarget: typeof EventTarget,
|
||
AbortController: typeof AbortController,
|
||
WebAssembly: typeof WebAssembly,
|
||
queueMicrotask: typeof queueMicrotask,
|
||
});
|
||
}, enumerable: false, configurable: true });
|
||
`;
|
||
}
|
||
|
||
function classifyError(err) {
|
||
const message = err && err.message ? err.message : String(err);
|
||
const name = err && err.name ? err.name : 'Error';
|
||
let m = message.match(/^([A-Za-z_$][\w$]*) is not defined$/);
|
||
if (name === 'ReferenceError' && m) return { type: 'missing-global', name, message, missing: m[1] };
|
||
m = message.match(/Cannot read (?:properties|property) of undefined \(reading ['"]([^'"]+)['"]\)/);
|
||
if (m) return { type: 'missing-property', name, message, missing: m[1] };
|
||
m = message.match(/([A-Za-z0-9_$\.]+) is not a function/);
|
||
if (m) return { type: 'missing-method', name, message, missing: m[1] };
|
||
return { type: 'runtime-error', name, message };
|
||
}
|
||
|
||
function summarize(events, errors, addonResult, leakageCheck) {
|
||
const missingGlobals = [], missingMethods = [], missingProperties = [], specialObjects = [], proxyRiskSignals = [];
|
||
for (const e of errors) {
|
||
if (e.type === 'missing-global') missingGlobals.push(e.missing);
|
||
if (e.type === 'missing-method') missingMethods.push(e.missing);
|
||
if (e.type === 'missing-property') missingProperties.push(e.missing);
|
||
}
|
||
for (const e of events) {
|
||
if (e.path === 'document.all' || (e.path && e.path.startsWith('document.all.'))) specialObjects.push('document.all');
|
||
if (['ownKeys', 'getOwnPropertyDescriptor', 'getPrototypeOf', 'toPrimitive'].includes(e.type)) proxyRiskSignals.push(`${e.type}:${e.path || e.prop || ''}`);
|
||
if (e.path && e.path.endsWith('.toString')) proxyRiskSignals.push(`toString:${e.path}`);
|
||
}
|
||
const uniq = a => [...new Set(a)].filter(Boolean);
|
||
return {
|
||
eventCount: events.length,
|
||
missingGlobals: uniq(missingGlobals),
|
||
missingMethods: uniq(missingMethods),
|
||
missingProperties: uniq(missingProperties),
|
||
specialObjects: uniq(specialObjects),
|
||
nativeAddon: { available: !!(addonResult && addonResult.available), path: addonResult && addonResult.path || '', exports: addonResult && addonResult.exports || [] },
|
||
nodeLeakage: leakageCheck || {},
|
||
proxyRiskSignals: uniq(proxyRiskSignals).slice(0, 100),
|
||
runtimeErrors: errors,
|
||
};
|
||
}
|
||
|
||
function run(args) {
|
||
if (!args.target) throw new Error('必须提供 --target');
|
||
const targetFiles = args.target.split(',').map(s => s.trim()).filter(Boolean).map(p => path.resolve(p));
|
||
for (const f of targetFiles) if (!fs.existsSync(f)) throw new Error(`目标 JS 文件不存在:${f}`);
|
||
const fixture = readJson(args.fixture);
|
||
const addonResult = loadNativeAddon({ addon: args.addon });
|
||
const context = vm.createContext({}, { name: 'web-js-env-patcher' });
|
||
const errors = [];
|
||
let entryResult;
|
||
let entryFound = false;
|
||
|
||
new vm.Script(bootstrapSource(fixture), { filename: 'web-js-env-patcher-bootstrap.js' }).runInContext(context, { timeout: args.timeout });
|
||
|
||
if (addonResult && addonResult.available && addonResult.addon && addonResult.addon.createUndetectable) {
|
||
try {
|
||
context.__documentAllAddon = addonResult.addon.createUndetectable(function () { return undefined; });
|
||
vm.runInContext("Object.defineProperty(document, 'all', { get() { __push({ type: 'get', path: 'document.all' }); return globalThis.__documentAllAddon; }, enumerable: false, configurable: true });", context, { timeout: args.timeout });
|
||
} catch (err) {
|
||
errors.push({ type: 'native-addon-error', name: err.name || 'Error', message: err.message || String(err) });
|
||
}
|
||
}
|
||
|
||
for (const file of targetFiles) {
|
||
try {
|
||
const script = new vm.Script(fs.readFileSync(file, 'utf8'), { filename: file });
|
||
script.runInContext(context, { timeout: args.timeout });
|
||
} catch (err) { errors.push(classifyError(err)); }
|
||
}
|
||
|
||
const entry = args.entry || fixture.entry || '';
|
||
if (entry) {
|
||
try {
|
||
const callCode = `__callEntry(${JSON.stringify(entry)}, ${JSON.stringify(JSON.stringify(Array.isArray(fixture.args) ? fixture.args : []))})`;
|
||
const callResult = JSON.parse(vm.runInContext(callCode, context, { timeout: args.timeout }));
|
||
entryFound = !!callResult.entryFound;
|
||
entryResult = callResult.output;
|
||
if (!entryFound) errors.push({ type: 'entry-not-found', name: 'EntryError', message: `入口不是函数或不存在:${entry}`, missing: entry });
|
||
if (callResult.error) errors.push(classifyError(callResult.error));
|
||
} catch (err) { errors.push(classifyError(err)); }
|
||
}
|
||
|
||
let events = [];
|
||
let leakageCheck = {};
|
||
try { events = JSON.parse(vm.runInContext('__dumpEvents()', context, { timeout: args.timeout })); } catch (_) { events = []; }
|
||
try { leakageCheck = JSON.parse(vm.runInContext('__leakageCheck()', context, { timeout: args.timeout })); } catch (_) { leakageCheck = {}; }
|
||
|
||
const output = { entry, entryFound, output: entryResult, errors };
|
||
const summary = summarize(events, errors, addonResult, leakageCheck);
|
||
if (args.trace) { ensureParent(args.trace); fs.writeFileSync(args.trace, events.map(e => JSON.stringify(e)).join('\n') + (events.length ? '\n' : ''), 'utf8'); }
|
||
if (args.summary) { ensureParent(args.summary); fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + '\n', 'utf8'); }
|
||
if (args.output) { ensureParent(args.output); fs.writeFileSync(args.output, JSON.stringify(output, null, 2) + '\n', 'utf8'); }
|
||
return { targetFiles, fixture: args.fixture || '', trace: args.trace || '', summaryFile: args.summary || '', outputFile: args.output || '', output, summary };
|
||
}
|
||
|
||
function renderMarkdown(result) {
|
||
const lines = [];
|
||
lines.push('# Node.js 缺失环境追踪结果');
|
||
lines.push('');
|
||
lines.push(`- 目标 JS:${result.targetFiles.join(', ')}`);
|
||
lines.push(`- fixture:${result.fixture || '未提供'}`);
|
||
lines.push(`- 入口函数:${result.output.entry || '未指定'}`);
|
||
lines.push(`- 入口是否找到:${result.output.entry ? (result.output.entryFound ? '是' : '否') : '未指定'}`);
|
||
lines.push(`- trace 文件:${result.trace || '未写入'}`);
|
||
lines.push(`- 摘要文件:${result.summaryFile || '未写入'}`);
|
||
lines.push(`- 输出文件:${result.outputFile || '未写入'}`);
|
||
lines.push(`- native addon:${result.summary.nativeAddon.available ? '可用' : '不可用或未启用'}`);
|
||
lines.push(`- trace 事件数量:${result.summary.eventCount}`);
|
||
lines.push(`- 运行错误数量:${result.summary.runtimeErrors.length}`);
|
||
if (result.summary.nodeLeakage && Object.keys(result.summary.nodeLeakage).length) {
|
||
lines.push('');
|
||
lines.push('## Node 泄露自检');
|
||
for (const [k, v] of Object.entries(result.summary.nodeLeakage)) lines.push(`- ${k}:${v}`);
|
||
}
|
||
if (result.summary.runtimeErrors.length) {
|
||
lines.push('');
|
||
lines.push('## 运行错误');
|
||
for (const e of result.summary.runtimeErrors) lines.push(`- ${e.type}:${e.message}`);
|
||
}
|
||
if (result.summary.proxyRiskSignals.length) {
|
||
lines.push('');
|
||
lines.push('## Proxy 检测风险信号');
|
||
for (const e of result.summary.proxyRiskSignals.slice(0, 20)) lines.push(`- ${e}`);
|
||
}
|
||
return lines.join('\n') + '\n';
|
||
}
|
||
|
||
try {
|
||
const args = parseArgs(process.argv);
|
||
if (args.help) { console.log(usage()); process.exit(0); }
|
||
const result = run(args);
|
||
if (args.json) console.log(JSON.stringify(result, null, 2));
|
||
if (args.markdown) process.stdout.write(renderMarkdown(result));
|
||
if (result.output.errors.some(e => e.type === 'entry-not-found')) process.exit(2);
|
||
} catch (err) {
|
||
console.error(err.message || String(err));
|
||
console.error(usage());
|
||
process.exit(1);
|
||
}
|