OCSxAI/tests/ocs5_emulator.mjs

519 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* OCS 5.0 行为模拟器(测试专用,不是产品代码)
* ================================================
* 为保证「我们生成的题库配置 / 实际收到的 payload」与 OCS 真实行为一致,本脚本把
* OCS 5.0 源码里的关键纯函数逐段移植到可执行 JS,并用 Node 真实执行(保留 JS 的
* RegExp / Function / Object.values / JSON 往返语义,比 Python 复刻更接近 OCS)。
*
* 移植来源(本地解压源码 v5.0):
* packages/scripts/src/utils/answerer-env.ts
* - IMAGE_URL_REGEX (L190)
* - createImageSuggestion (L192-284)
* - buildAnswererEnv (L299-342)
* - IMAGE_OPTIMIZE_PLACEHOLDERS (L345)
* - IMAGE_ENV_REF_PATTERN (L348)
* - isAnswererWrapperSupportImageOptimize / isAnswererWrappersSupportImageOptimize (L367-391)
* packages/core/src/core/answer-wrapper/answer.wrapper.handler.ts
* - env 默认值 (L74-82)
* - POST data 字段解析:data 字段 handler 用 Function() 执行 / 字符串走
* resolvePlaceHolder (L123-144, L216-232)
* packages/core/src/core/answer-wrapper/answer.wrapper.parser.ts
* - fromObject 校验 (L8-50);from(string) 只做 JSON.parse (L52-79)
* packages/scripts/src/utils/answer-wrapper-security.ts
* - stripCommentsAndStrings / DANGEROUS_PATTERNS / detectHandlerRisks /
* scanAnswerWrapperRisks (L20-178)
*
* 未移植(与本测试无关,测试图片均满足前提):
* * imageToBase64 的浏览器/Node 网络分支 —— 测试里通过 imagePayloads 注入转换结果,
* 等价于 OCS 在浏览器中成功把图片转成 base64 的结果;
* * getImageSize / upscalePngDataUrl 的 <14px 放大分支 —— 测试图片均远大于 14px。
*
* 用法(stdin 传 JSON,stdout 返回 JSON):
* node ocs5_emulator.mjs < request.json
* request.op = "detect" : 检测题库配置(图片题优化支持性 + 可疑代码扫描 + parser 校验)
* request.op = "build" : 模拟 buildAnswererEnv + POST data 解析,产出真实 OCS 会发出的请求体
*/
const IMAGE_OPTIMIZE_PLACEHOLDERS = ['${images}', '${suggestion_title}', '${suggestion_options}'];
const IMAGE_ENV_REF_PATTERN = /(^|[^\w$])(images|suggestion_title|suggestion_options)(?![\w$])/;
// 源码 L190:仅匹配带图片扩展名的 URL(DOM 调用方会直接传 titleImages/optionsImages,绕过该正则)
const IMAGE_URL_REGEX =
/https?:\/\/[^\s\u4e00-\u9fff\uff00-\uffef]+?\.(?:png|jpe?g|gif|bmp|webp|svg)(?:\?[^\s\u4e00-\u9fff\uff00-\uffef]*)?/gi;
// ─────────────────────────── answerer-env.ts ───────────────────────────
function isAnswererWrapperSupportImageOptimize(wrapper) {
// L367-383 原样移植
if (!wrapper || String(wrapper.method || 'get').toLowerCase() !== 'post') {
return false;
}
for (const value of Object.values(wrapper.data || {})) {
if (typeof value === 'string') {
if (IMAGE_OPTIMIZE_PLACEHOLDERS.some((placeholder) => value.includes(placeholder))) {
return true;
}
} else if (value && typeof value === 'object' && typeof value.handler === 'string') {
if (IMAGE_ENV_REF_PATTERN.test(value.handler)) {
return true;
}
}
}
return false;
}
function isAnswererWrappersSupportImageOptimize(wrappers) {
return wrappers.some(isAnswererWrapperSupportImageOptimize);
}
function normalizeToPngDataUrl(dataUrl) {
// L178-181
const m = dataUrl.match(/^data:[^;,]+.*?;base64,(.+)$/);
return `data:image/png;base64,${m ? m[1] : dataUrl}`;
}
/** 模拟 imageToBase64:data: 原样返回;否则用注入的转换结果(等价浏览器 canvas 成功转换) */
function imageToBase64(url, imagePayloads) {
if (url.startsWith('data:')) {
return Promise.resolve(url);
}
if (imagePayloads && typeof imagePayloads[url] === 'string') {
return Promise.resolve(imagePayloads[url]);
}
// 未注入时生成确定性占位 base64(只用于结构测试,不是合法图片)
return Promise.resolve(`data:image/png;base64,${Buffer.from('OCS_EMULATOR:' + url).toString('base64')}`);
}
/** L192-284 移植(跳过 <14px upscale 分支,见文件头说明) */
async function createImageSuggestion(title, options, titleImages, optionImages, imagePayloads) {
const titleUrls = titleImages && titleImages.length ? titleImages.slice() : title.match(IMAGE_URL_REGEX) || [];
const optionUrls = optionImages && optionImages.length ? optionImages.slice() : options?.match(IMAGE_URL_REGEX) || [];
if (titleUrls.length === 0 && optionUrls.length === 0) return undefined;
const allUrls = [];
const seen = new Set();
for (const url of [...titleUrls, ...optionUrls]) {
if (!seen.has(url)) {
seen.add(url);
allUrls.push(url);
}
}
const base64Map = new Map();
await Promise.all(
allUrls.map(async (url) => {
try {
base64Map.set(url, await imageToBase64(url, imagePayloads));
} catch {
// 转换失败跳过(与源码一致)
}
})
);
if (base64Map.size === 0) return undefined;
const images = [];
const urls = [];
const placeholderMap = new Map();
for (const url of allUrls) {
const base64 = base64Map.get(url);
if (!base64) continue;
// L241-243:统一重写为 data:image/png;base64,{b64}
const normalized = normalizeToPngDataUrl(base64);
// L253:' [图片N] ',左右各一个空格(注意源码注释写“两个空格”,实际模板各一个)
placeholderMap.set(url, ` [图片${images.length + 1}] `);
images.push(normalized);
urls.push(url);
}
if (images.length === 0) return undefined;
let suggestionTitle = title;
for (const [url, placeholder] of placeholderMap) {
suggestionTitle = suggestionTitle.split(url).join(placeholder);
}
let suggestionOptions;
if (options) {
const hasOptionImages = optionUrls.some((url) => base64Map.has(url));
if (hasOptionImages) {
suggestionOptions = options;
for (const [url, placeholder] of placeholderMap) {
suggestionOptions = suggestionOptions.split(url).join(placeholder);
}
}
}
return {
images,
urls,
suggestion_title: suggestionTitle,
...(suggestionOptions !== undefined ? { suggestion_options: suggestionOptions } : {})
};
}
/** L286-297 按行去重 */
function dedupeLines(str) {
const seen = new Set();
const result = [];
for (const line of str.split('\n')) {
if (!seen.has(line)) {
seen.add(line);
result.push(line);
}
}
return result.join('\n');
}
/** L299-342 移植 */
async function buildAnswererEnv(params, imagePayloads) {
const rawOptions = Array.isArray(params.options)
? params.options.filter((o) => o != null && o !== '').join('\n')
: params.options ?? '';
const options = dedupeLines(rawOptions);
const env = {
type: params.type || 'unknown',
title: params.title,
options
};
let imageUrls = [];
if (params.enableImageOptimize) {
const suggestion = await createImageSuggestion(
params.title,
options,
params.titleImages,
params.optionsImages,
imagePayloads
);
if (suggestion) {
env.images = suggestion.images;
imageUrls = suggestion.urls;
env.suggestion_title = suggestion.suggestion_title;
if (suggestion.suggestion_options !== undefined) {
env.suggestion_options = dedupeLines(suggestion.suggestion_options);
}
}
}
return { env, imageUrls };
}
// ─────────────────── core: answer.wrapper.handler.ts ───────────────────
/** L226-232 resolvePlaceHolder */
function resolvePlaceHolder(data, env, options) {
if (typeof data === 'string') {
const matches = data.match(/\${(.*?)}/g) || [];
matches.forEach((placeHolder) => {
const value = env[placeHolder.replace(/\${(.*)}/, '$1')];
data = data.replace(placeHolder, options?.encodeURI ? encodeURIComponent(value) : value);
});
} else if (typeof data === 'object') {
const keys = Object.keys(data);
for (const key of keys) {
data[key] = resolvePlaceHolder(data[key], env, options);
}
}
return data;
}
/** L43-234 POST 分支移植:返回每个 wrapper 实际会发出的 JSON body(连同 env 默认值) */
function defaultAnswerWrapperHandler(wrappers, env) {
const temp = JSON.parse(JSON.stringify(wrappers));
if (temp.length === 0) throw new Error('题库配置不能为空,请配置后重新开始自动答题。');
// L74-82 env 默认值:避免占位符被替换成 'undefined' 字面量
if (env.title === undefined) env.title = '';
if (env.options === undefined) env.options = '';
if (env.type === undefined) env.type = 'unknown';
if (env.images === undefined) env.images = [];
if (env.suggestion_title === undefined) env.suggestion_title = '';
if (env.suggestion_options === undefined) env.suggestion_options = '';
const requests = [];
for (const wrapper of temp) {
const {
name = '未知题库',
homepage = '#',
method = 'get',
type = 'fetch',
contentType = 'json',
responseType = 'json',
headers = {},
data: wrapperData = {},
handler = 'return (res)=> [JSON.stringify(res), undefined]'
} = wrapper;
if (method.toLocaleLowerCase() === 'post') {
if (!Object.keys(headers).some((k) => k.toLowerCase() === 'content-type')) {
headers['Content-Type'] = 'application/json';
}
}
let requestData;
if (method.toLocaleLowerCase() === 'post') {
const data = Object.create({});
Object.keys(wrapperData).forEach((key) => {
if (typeof wrapperData[key] === 'object' && Reflect.has(wrapperData[key], 'handler')) {
const h = Function(Reflect.get(wrapperData[key], 'handler'))();
if (typeof h !== 'function') throw new Error('data 字段解析器必须返回一个函数');
Reflect.set(data, key, h(env));
} else {
Reflect.set(data, key, resolvePlaceHolder(wrapperData[key], env));
}
});
requestData = data;
} else {
// GET:data 会被拼到 query(本配置为 POST,仅作对照)
requestData = {};
}
// 响应 handler 也执行一次,确保 DSL 本身可被 Function 构造(不校验响应逻辑)
const responseHandler = Function(handler)();
if (typeof responseHandler !== 'function') {
throw new Error('handler 响应处理器必须返回一个函数');
}
const body = JSON.parse(JSON.stringify(requestData));
requests.push({
name,
url: wrapper.url,
method,
contentType,
requestType: type,
headers,
body,
bodySize: JSON.stringify(body).length
});
}
return { envAfterDefaults: env, requests };
}
// ────────────── core: answer.wrapper.parser.ts ──────────────
function parseFromObject(json) {
const aw = json;
const errors = [];
if (!aw || !Array.isArray(aw)) return { ok: false, errors: ['题库配置格式错误!'] };
if (!aw.length) return { ok: false, errors: ['题库为空!'] };
for (let i = 0; i < aw.length; i++) {
const item = aw[i] || {};
if (typeof item.name !== 'string') errors.push(`第 ${i + 1} 个题库的 名字(name) 为空`);
if (typeof item.url !== 'string') errors.push(`第 ${i + 1} 个题库的 接口地址(url) 为空`);
if (typeof item.handler !== 'string') errors.push(`第 ${i + 1} 个题库的 解析器(handler) 为空`);
if (item.headers && typeof item.headers !== 'object') errors.push(`第 ${i + 1} 个题库的 头部信息(header) 应为 对象 格式`);
if (item.data && typeof item.data !== 'object') errors.push(`第 ${i + 1} 个题库的 提交数据(data) 应为 对象 格式`);
if (['json', 'text'].every((x) => x !== item.contentType) && item.contentType) {
errors.push(`第 ${i + 1} 个题库的 contentType 非法`);
}
if (['post', 'get'].every((x) => x !== item.method) && item.method) {
errors.push(`第 ${i + 1} 个题库的 method 非法`);
}
if (['fetch', 'GM_xmlhttpRequest'].every((x) => x !== item.type) && item.type) {
errors.push(`第 ${i + 1} 个题库的 type 非法`);
}
}
return { ok: errors.length === 0, errors };
}
// ────────────── scripts: answer-wrapper-security.ts ──────────────
const DANGEROUS_PATTERNS = [
{ pattern: /\beval\b/, feature: 'eval' },
{ pattern: /\bFunction\s*\(/, feature: 'Function()' },
{ pattern: /\bfetch\s*\(/, feature: 'fetch' },
{ pattern: /\bXMLHttpRequest\b/, feature: 'XMLHttpRequest' },
{ pattern: /\bGM_xmlhttpRequest\b|\bGM\s*\.\s*xmlHttpRequest\b/, feature: 'GM_xmlhttpRequest' },
{ pattern: /\bGM_\w+|\bGM\s*\.\s*\w+/, feature: 'GM API' },
{ pattern: /\blocalStorage\b|\bsessionStorage\b|\bindexedDB\b/, feature: '本地存储' },
{ pattern: /\bcookie\b/i, feature: 'cookie' },
{ pattern: /\bsendBeacon\b/, feature: 'sendBeacon' },
{ pattern: /\bWebSocket\b|\bEventSource\b/, feature: 'WebSocket/EventSource' },
{ pattern: /\blocation\b/, feature: 'location' },
{ pattern: /\bwindow\b|\bglobalThis\b|\bunsafeWindow\b|\btop\b|\bparent\b|\bopener\b/, feature: '全局对象' },
{ pattern: /\bdocument\b/, feature: 'document' },
{ pattern: /\bpostMessage\b/, feature: 'postMessage' },
{ pattern: /\bimport\s*\(/, feature: 'import()' },
{ pattern: /\brequire\s*\(/, feature: 'require()' },
{ pattern: /\.constructor\b|\b__proto__\b/, feature: '原型链访问' },
{ pattern: /\bfromCharCode\b|\batob\b|\bunescape\b/, feature: '解码函数' },
{ pattern: /\bWorker\b/, feature: 'Worker' },
{
pattern: /\[\s*['"`][^\]'"]*['"`]\s*\+|\+\s*['"`][^\]'"]*['"`]\s*\]/,
feature: '拼接属性访问'
}
];
/** L77-138 移植:去掉注释与字符串字面量(避免字符串内关键词误报) */
function stripCommentsAndStrings(code) {
const findings = [];
let stripped = '';
let i = 0;
while (i < code.length) {
const ch = code[i];
const next = code[i + 1];
if (ch === '/' && next === '/') {
while (i < code.length && code[i] !== '\n') i++;
continue;
}
if (ch === '/' && next === '*') {
i += 2;
while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++;
i += 2;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
const quote = ch;
let str = '';
i++;
while (i < code.length && code[i] !== quote) {
if (code[i] === '\\') {
str += code[i] + (code[i + 1] || '');
i += 2;
continue;
}
if (quote !== '`' && code[i] === '\n') break;
str += code[i];
i++;
}
i++;
if (/\\x[0-9a-fA-F]{2}|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\}/.test(str)) {
findings.push({ where: '', feature: '转义字符' });
}
if (str.length > 100 && /^[A-Za-z0-9+/=\s]+$/.test(str)) {
findings.push({ where: '', feature: '超长编码串' });
}
if (quote === '`') {
for (const match of str.matchAll(/\$\{([^{}]*)\}/g)) {
stripped += ' ' + match[1] + ' ';
}
}
stripped += '""';
continue;
}
stripped += ch;
i++;
}
return { stripped, findings };
}
function detectHandlerRisks(code, where = '') {
const { stripped, findings } = stripCommentsAndStrings(code);
for (const { pattern, feature } of DANGEROUS_PATTERNS) {
if (pattern.test(stripped)) findings.push({ where, feature });
}
const seen = new Set();
return findings
.map((f) => ({ ...f, where }))
.filter((f) => {
const key = f.where + '|' + f.feature;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function scanAnswerWrapperRisks(aw) {
const findings = [];
if (typeof aw.handler === 'string') findings.push(...detectHandlerRisks(aw.handler, 'handler 解析器'));
if (aw.data && typeof aw.data === 'object') {
for (const key of Object.keys(aw.data)) {
const field = aw.data[key];
if (field && typeof field === 'object' && typeof field.handler === 'string') {
findings.push(...detectHandlerRisks(field.handler, `data.${key} 字段解析器`));
}
}
}
return findings;
}
// ────────────────────────────── ops ──────────────────────────────
async function opDetect(req) {
const wrappers = req.wrappers || [];
const perWrapper = wrappers.map((w, i) => ({
index: i,
name: w && w.name,
method: w && w.method,
supportsImageOptimize: isAnswererWrapperSupportImageOptimize(w),
risks: scanAnswerWrapperRisks(w || {}),
parser: parseFromObject([w])
}));
return {
supportsImageOptimize: isAnswererWrappersSupportImageOptimize(wrappers),
perWrapper,
directCases: (req.cases || []).map((c) => ({
name: c.name,
result: isAnswererWrapperSupportImageOptimize(c.wrapper),
// 单个对象越界字段不影响判断,此处同时输出 Object.values 数量自检
dataValueCount: Object.values((c.wrapper && c.wrapper.data) || {}).length
}))
};
}
async function opBuild(req) {
const wrappers = req.wrappers || [];
const scenarios = req.scenarios || [];
const out = [];
for (const scenario of scenarios) {
const imagePayloads = scenario.imagePayloads || {};
const { env, imageUrls } = await buildAnswererEnv(
{
type: scenario.type,
title: scenario.title,
options: scenario.options,
titleImages: scenario.titleImages,
optionsImages: scenario.optionsImages,
enableImageOptimize: scenario.enableImageOptimize !== false
},
imagePayloads
);
const { envAfterDefaults, requests } = defaultAnswerWrapperHandler(wrappers, env);
out.push({
name: scenario.name,
imageUrls,
envAfterDefaults,
requests
});
}
return { scenarios: out };
}
function opParse(req) {
const fromObject = parseFromObject(req.wrappers || []);
// from(string) 只做 JSON.parse,不校验字段(parser.ts L69-79)
let stringParseOk = true;
let stringParseError = null;
try {
JSON.parse(req.rawString || '');
} catch (e) {
stringParseOk = false;
stringParseError = String(e && e.message);
}
return { fromObject, stringParseOk, stringParseError };
}
async function main() {
let raw = '';
for await (const chunk of process.stdin) raw += chunk;
const req = JSON.parse(raw || '{}');
let result;
switch (req.op) {
case 'detect':
result = await opDetect(req);
break;
case 'build':
result = await opBuild(req);
break;
case 'parse':
result = opParse(req);
break;
default:
throw new Error(`unknown op: ${req.op}`);
}
process.stdout.write(JSON.stringify(result));
}
main().catch((e) => {
process.stderr.write(String((e && e.stack) || e) + '\n');
process.exit(1);
});