import type { BrowserPageCallableExecution, BrowserTransformBuiltinOperation, BrowserTransformDirection, BrowserTransformExecution, BrowserTransformHeader, BrowserTransformNodeReference, BrowserTransformPacket, BrowserTransformValueSummary, BrowserTransformValueEncoding, } from '@/types/models'; import { ExtensionError } from '@/shared/errors'; const MAX_BODY_BYTES = 8 * 1024 * 1024; const MAX_PATH_LENGTH = 512; const MAX_PATH_SEGMENTS = 64; const MAX_JSON_DEPTH = 64; const MAX_JSON_NODES = 100_000; const MAX_PIPELINE_NODES = 64; const BLOCKED_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']); const BUILTIN_OPERATIONS = new Set([ 'value.literal', 'json.stringify', 'json.parse', 'text.toString', 'url.encode', 'url.decode', 'base64.encode', 'base64.decode', 'hex.encode', 'hex.decode', 'object.pick', 'object.compose', 'form.compose', 'form.serialize', ]); const encoder = new TextEncoder(); const decoder = new TextDecoder(); type BodyFormat = 'empty' | 'json' | 'form' | 'text'; interface TransformContext { method?: string; url: string; statusCode?: number; headers: Record; query: Record; body: unknown; text: string; bodyBase64: string; } export type PageCallableInvoker = ( callableId: string, args: unknown[], ) => Promise; function decodeBase64(value: string): Uint8Array { if (!value) return new Uint8Array(); if (value.length > Math.ceil(MAX_BODY_BYTES / 3) * 4 + 8) { throw new ExtensionError('transform_body_too_large', '转换数据包 body 超过 8 MiB 限制'); } let binary: string; try { binary = atob(value); } catch { throw new ExtensionError('transform_invalid_body', 'Base64 数据无效'); } if (binary.length > MAX_BODY_BYTES) throw new ExtensionError('transform_body_too_large', '转换数据包 body 超过 8 MiB 限制'); return Uint8Array.from(binary, (character) => character.charCodeAt(0)); } function encodeBase64(bytes: Uint8Array): string { if (bytes.byteLength > MAX_BODY_BYTES) throw new ExtensionError('transform_body_too_large', '页面转换结果超过 8 MiB 限制'); let binary = ''; for (let offset = 0; offset < bytes.length; offset += 8_192) { binary += String.fromCharCode(...bytes.subarray(offset, offset + 8_192)); } return btoa(binary); } function headerRecord(headers: BrowserTransformHeader[]): Record { const output = Object.create(null) as Record; for (const header of headers) { output[header.name] = header.value; output[header.name.toLowerCase()] = header.value; } return output; } function queryRecord(url: string): Record { const output = Object.create(null) as Record; try { for (const [key, value] of new URL(url).searchParams) { const previous = output[key]; output[key] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value]; } } catch { /* URL is validated at the protocol boundary. */ } return output; } function parseForm(text: string): Record { const output = Object.create(null) as Record; for (const [key, value] of new URLSearchParams(text)) { const previous = output[key]; output[key] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value]; } return output; } function parseBody(bytes: Uint8Array, headers: Record): { text: string; body: unknown; format: BodyFormat } { const text = decoder.decode(bytes); if (!text.trim()) return { text, body: '', format: 'empty' }; try { return { text, body: JSON.parse(text) as unknown, format: 'json' }; } catch { /* Not JSON. */ } const contentType = headers['content-type']?.toLowerCase() || ''; if (contentType.includes('application/x-www-form-urlencoded')) { return { text, body: parseForm(text), format: 'form' }; } return { text, body: text, format: 'text' }; } function pathSegments(path: string): string[] { const trimmed = path.trim(); if (!trimmed || trimmed === '$') return []; if (trimmed.length > MAX_PATH_LENGTH) throw new ExtensionError('transform_invalid_path', '转换值路径过长'); const normalized = trimmed.startsWith('$.') ? trimmed.slice(2) : trimmed; const segments = normalized.split('.').filter(Boolean); if (segments.length > MAX_PATH_SEGMENTS || segments.some((segment) => BLOCKED_PATH_SEGMENTS.has(segment))) { throw new ExtensionError('transform_invalid_path', `不允许的转换值路径: ${path}`); } return segments; } export function readTransformValue(input: unknown, path: string): unknown { let current = input; for (const segment of pathSegments(path)) { if (current === null || current === undefined || typeof current !== 'object') { throw new ExtensionError('transform_value_missing', `转换值路径不存在: ${path}`); } if (Array.isArray(current)) { const index = Number(segment); if (!Number.isSafeInteger(index) || index < 0 || index >= current.length) { throw new ExtensionError('transform_value_missing', `转换值路径不存在: ${path}`); } current = current[index]; continue; } const record = current as Record; if (!Object.prototype.hasOwnProperty.call(record, segment)) { throw new ExtensionError('transform_value_missing', `转换值路径不存在: ${path}`); } current = record[segment]; } return current; } interface JsonCloneState { nodes: number; seen: WeakSet } function cloneJsonBody(value: unknown, state: JsonCloneState = { nodes: 0, seen: new WeakSet() }, depth = 0): unknown { state.nodes += 1; if (state.nodes > MAX_JSON_NODES) throw new ExtensionError('transform_value_too_large', '转换上下文包含过多节点'); if (!value || typeof value !== 'object') return value; if (depth >= MAX_JSON_DEPTH) throw new ExtensionError('transform_value_too_deep', `转换上下文嵌套超过 ${MAX_JSON_DEPTH} 层`); if (state.seen.has(value)) throw new ExtensionError('transform_value_invalid', '转换上下文包含循环引用'); state.seen.add(value); try { if (Array.isArray(value)) return value.map((item) => cloneJsonBody(item, state, depth + 1)); const output: Record = {}; for (const [key, item] of Object.entries(value as Record)) { if (!BLOCKED_PATH_SEGMENTS.has(key)) output[key] = cloneJsonBody(item, state, depth + 1); } return output; } finally { state.seen.delete(value); } } function writeObjectPath(input: unknown, path: string, value: unknown): unknown { const segments = pathSegments(path); if (!segments.length) return value; const root = cloneJsonBody(input); if (!root || typeof root !== 'object') throw new ExtensionError('transform_output_invalid', `目标 ${path} 需要结构化 body`); let current = root as Record | unknown[]; segments.forEach((segment, index) => { const last = index === segments.length - 1; if (Array.isArray(current)) { const arrayIndex = Number(segment); if (!Number.isSafeInteger(arrayIndex) || arrayIndex < 0 || arrayIndex >= current.length) { throw new ExtensionError('transform_output_invalid', `目标数组路径不存在: ${path}`); } if (last) current[arrayIndex] = value; else { if (!current[arrayIndex] || typeof current[arrayIndex] !== 'object') current[arrayIndex] = /^\d+$/.test(segments[index + 1]) ? [] : {}; current = current[arrayIndex] as Record | unknown[]; } return; } if (last) current[segment] = value; else { if (!current[segment] || typeof current[segment] !== 'object') current[segment] = /^\d+$/.test(segments[index + 1]) ? [] : {}; current = current[segment] as Record | unknown[]; } }); return root; } function bytesValue(value: unknown, encoding: BrowserTransformValueEncoding = 'auto'): Uint8Array { if (value && typeof value === 'object' && (value as Record).type === 'bytes' && typeof (value as Record).base64 === 'string') { return decodeBase64(String((value as Record).base64)); } if (encoding === 'base64') { if (typeof value !== 'string') throw new ExtensionError('transform_output_invalid', 'Base64 值必须是字符串或字节值'); return decodeBase64(value); } if (encoding === 'json') return encoder.encode(JSON.stringify(value)); if (typeof value === 'string') return encoder.encode(value); return encoder.encode(JSON.stringify(value)); } function stringValue(value: unknown): string { if (typeof value === 'string') return value; if (value === undefined || value === null) return ''; if (value && typeof value === 'object' && (value as Record).type === 'bytes') return decoder.decode(bytesValue(value)); if (typeof value === 'object') return JSON.stringify(value); return String(value); } function hexEncode(bytes: Uint8Array): string { let output = ''; for (const byte of bytes) output += byte.toString(16).padStart(2, '0'); return output; } function hexDecode(value: string): Uint8Array { if (!/^(?:[0-9a-f]{2})*$/i.test(value)) throw new ExtensionError('transform_builtin_invalid', 'Hex 输入无效'); const output = new Uint8Array(value.length / 2); for (let index = 0; index < output.length; index += 1) output[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); return output; } function byteResult(bytes: Uint8Array): { type: 'bytes'; byteLength: number; base64: string } { return { type: 'bytes', byteLength: bytes.byteLength, base64: encodeBase64(bytes) }; } export function summarizeTransformValue(value: unknown): BrowserTransformValueSummary { if (value === null) return { type: 'null' }; if (value === undefined) return { type: 'undefined' }; if (typeof value === 'boolean') return { type: 'boolean' }; if (typeof value === 'number') return { type: 'number' }; if (typeof value === 'string') return { type: 'string', byteLength: encoder.encode(value).byteLength }; if (Array.isArray(value)) return { type: 'array', itemCount: value.length }; if (typeof value === 'object') { const record = value as Record; if (record.type === 'bytes') { const byteLength = typeof record.byteLength === 'number' && Number.isSafeInteger(record.byteLength) ? Math.max(0, record.byteLength) : typeof record.base64 === 'string' ? Math.floor(record.base64.length * 0.75) : undefined; return { type: 'bytes', byteLength }; } return { type: 'object', itemCount: Object.keys(record).length }; } return { type: 'undefined' }; } interface DifferenceBudget { compared: number; changes: number } function equivalentTransformValue(left: unknown, right: unknown, budget: DifferenceBudget, depth = 0): boolean { budget.compared += 1; if (Object.is(left, right)) return true; if (budget.compared > 20_000 || depth > 16 || !left || !right || typeof left !== 'object' || typeof right !== 'object') return false; if (Array.isArray(left) || Array.isArray(right)) { if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; return left.every((item, index) => equivalentTransformValue(item, right[index], budget, depth + 1)); } const leftRecord = left as Record; const rightRecord = right as Record; const leftKeys = Object.keys(leftRecord).filter((key) => !BLOCKED_PATH_SEGMENTS.has(key)).sort(); const rightKeys = Object.keys(rightRecord).filter((key) => !BLOCKED_PATH_SEGMENTS.has(key)).sort(); return leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index] && equivalentTransformValue(leftRecord[key], rightRecord[key], budget, depth + 1)); } function appendTransformChanges( before: unknown, after: unknown, path: string, changes: BrowserTransformExecution['fieldChanges'], budget: DifferenceBudget, depth = 0, ): void { if (changes.length >= 128 || equivalentTransformValue(before, after, budget)) return; if (before === undefined || after === undefined) { changes.push({ path, change: before === undefined ? 'added' : 'removed', before: before === undefined ? undefined : summarizeTransformValue(before), after: after === undefined ? undefined : summarizeTransformValue(after), }); return; } if (depth < 8 && before && after && typeof before === 'object' && typeof after === 'object' && !Array.isArray(before) && !Array.isArray(after)) { const beforeRecord = before as Record; const afterRecord = after as Record; const keys = [...new Set([...Object.keys(beforeRecord), ...Object.keys(afterRecord)])] .filter((key) => !BLOCKED_PATH_SEGMENTS.has(key)).sort().slice(0, 256); for (const key of keys) { appendTransformChanges(beforeRecord[key], afterRecord[key], `${path}.${key}`, changes, budget, depth + 1); if (changes.length >= 128) break; } return; } changes.push({ path, change: 'changed', before: summarizeTransformValue(before), after: summarizeTransformValue(after), }); } function referenceLabel(reference: BrowserTransformNodeReference): string { return `${reference.nodeId}${reference.path ? `.${reference.path}` : ''}`; } function optionStrings(options: Record | undefined, key: string, max = 64): string[] { const value = options?.[key]; if (!Array.isArray(value) || value.length > max || value.some((item) => typeof item !== 'string')) { throw new ExtensionError('transform_builtin_invalid', `内置操作需要字符串数组 options.${key}`); } return value as string[]; } function executeBuiltin(operation: BrowserTransformBuiltinOperation, inputs: unknown[], options?: Record): unknown { if (operation === 'value.literal') { if (inputs.length) throw new ExtensionError('transform_builtin_invalid', '固定值操作不接受输入'); const value = options?.value; if (!['string', 'number', 'boolean'].includes(typeof value) && value !== null) { throw new ExtensionError('transform_builtin_invalid', '固定值只允许字符串、数字、布尔值或 null'); } return value; } const one = () => { if (inputs.length !== 1) throw new ExtensionError('transform_builtin_invalid', `${operation} 需要 1 个输入`); return inputs[0]; }; if (operation === 'json.stringify') return JSON.stringify(one()); if (operation === 'json.parse') { try { return JSON.parse(stringValue(one())) as unknown; } catch { throw new ExtensionError('transform_builtin_invalid', 'JSON 输入无效'); } } if (operation === 'text.toString') return stringValue(one()); if (operation === 'url.encode') return encodeURIComponent(stringValue(one())); if (operation === 'url.decode') { try { return decodeURIComponent(stringValue(one())); } catch { throw new ExtensionError('transform_builtin_invalid', 'URL 编码输入无效'); } } if (operation === 'base64.encode') return encodeBase64(bytesValue(one())); if (operation === 'base64.decode') return byteResult(decodeBase64(stringValue(one()))); if (operation === 'hex.encode') return hexEncode(bytesValue(one())); if (operation === 'hex.decode') return byteResult(hexDecode(stringValue(one()))); if (operation === 'object.pick') { const source = one(); const paths = optionStrings(options, 'paths'); const keys = Array.isArray(options?.keys) ? optionStrings(options, 'keys') : paths.map((path) => path.split('.').at(-1) || path); if (paths.length !== keys.length) throw new ExtensionError('transform_builtin_invalid', 'object.pick 的 paths 与 keys 数量必须一致'); return Object.fromEntries(paths.map((path, index) => [keys[index], cloneJsonBody(readTransformValue(source, path))])); } if (operation === 'object.compose') { const keys = optionStrings(options, 'keys'); if (keys.length !== inputs.length || keys.some((key) => BLOCKED_PATH_SEGMENTS.has(key))) { throw new ExtensionError('transform_builtin_invalid', 'object.compose 的 keys 必须与输入一一对应'); } return Object.fromEntries(keys.map((key, index) => [key, cloneJsonBody(inputs[index])])); } if (operation === 'form.serialize') { const source = one(); if (!source || typeof source !== 'object' || Array.isArray(source)) { throw new ExtensionError('transform_builtin_invalid', 'form.serialize 需要对象输入'); } const entries = Object.entries(source as Record); if (entries.length > 64 || entries.some(([key]) => BLOCKED_PATH_SEGMENTS.has(key))) { throw new ExtensionError('transform_builtin_invalid', 'form.serialize 的字段数量或名称无效'); } const form = new URLSearchParams(); entries.forEach(([key, value]) => { if (Array.isArray(value)) value.forEach((item) => form.append(key, stringValue(item))); else form.append(key, stringValue(value)); }); return form.toString(); } const keys = optionStrings(options, 'keys'); if (keys.length !== inputs.length) throw new ExtensionError('transform_builtin_invalid', 'form.compose 的 keys 必须与输入一一对应'); const form = new URLSearchParams(); keys.forEach((key, index) => { const value = inputs[index]; if (Array.isArray(value)) value.forEach((item) => form.append(key, stringValue(item))); else form.append(key, stringValue(value)); }); return form.toString(); } function resolveReference(results: Map, reference: BrowserTransformNodeReference): unknown { if (!results.has(reference.nodeId)) throw new ExtensionError('transform_pipeline_invalid', `节点引用不存在: ${reference.nodeId}`); const value = results.get(reference.nodeId); return reference.path ? readTransformValue(value, reference.path) : value; } function validDestination(destination: string): boolean { if (destination === 'body') return true; if (destination.startsWith('body.')) { pathSegments(destination.slice(5)); return true; } if (destination.toLowerCase().startsWith('header.')) return Boolean(destination.slice(7)) && !/[\r\n:]/.test(destination.slice(7)); if (destination.startsWith('query.')) return Boolean(destination.slice(6)) && !/[\r\n&#=]/.test(destination.slice(6)); return false; } export function assertTransformDirection(direction: BrowserTransformDirection): void { if (!direction.nodes.length || direction.nodes.length > MAX_PIPELINE_NODES) { throw new ExtensionError('transform_pipeline_empty', `转换 Pipeline 必须包含 1-${MAX_PIPELINE_NODES} 个节点`); } const seen = new Set(); let outputCount = 0; for (const node of direction.nodes) { if (!node.id.trim() || !node.name.trim() || seen.has(node.id)) throw new ExtensionError('transform_pipeline_invalid', `Pipeline 节点 ID 无效或重复: ${node.id}`); const references = node.kind === 'builtin' ? node.inputs : node.kind === 'page.call' ? node.arguments : node.kind === 'output.write' ? [node.source] : []; for (const reference of references) { if (!seen.has(reference.nodeId)) throw new ExtensionError('transform_pipeline_invalid', `节点 ${node.name} 引用了尚未产生的 ${reference.nodeId}`); if (reference.path) pathSegments(reference.path); } if (node.kind === 'context.read') pathSegments(node.path); if (node.kind === 'builtin' && !BUILTIN_OPERATIONS.has(node.operation)) throw new ExtensionError('transform_pipeline_invalid', `不支持的内置操作: ${node.operation}`); if (node.kind === 'page.call' && !node.callableId.trim()) throw new ExtensionError('transform_pipeline_invalid', `节点 ${node.name} 未绑定页面函数`); if (node.kind === 'output.write') { outputCount += 1; if (!validDestination(node.destination.trim())) throw new ExtensionError('transform_output_invalid', `不支持的输出目标: ${node.destination}`); } seen.add(node.id); } if (!outputCount) throw new ExtensionError('transform_pipeline_empty', '转换 Pipeline 缺少 output.write 节点'); } export function wildcardUrlMatches(pattern: string, url: string): boolean { const value = pattern.trim(); if (!value || value === '*') return true; const escaped = value.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); const matcher = new RegExp(`^${escaped}$`, 'i'); if (matcher.test(url)) return true; try { return matcher.test(new URL(url).pathname); } catch { return false; } } export function assertTransformRoute(methods: string[], urlPattern: string, packet: BrowserTransformPacket, pageOrigin?: string): void { if (methods.length && (!packet.method || !methods.includes(packet.method.toUpperCase()))) { throw new ExtensionError('transform_route_mismatch', `请求方法 ${packet.method || '(missing)'} 不匹配转换配置`); } const explicitOriginPattern = /^(?:https?|\*):\/\//i.test(urlPattern.trim()); if (pageOrigin && !explicitOriginPattern) { let packetOrigin = ''; try { packetOrigin = new URL(packet.url).origin; } catch { /* validated by protocol */ } if (packetOrigin !== pageOrigin) throw new ExtensionError('transform_route_mismatch', `URL 来源 ${packetOrigin || '(invalid)'} 不匹配页面来源 ${pageOrigin}`); } if (!wildcardUrlMatches(urlPattern, packet.url)) throw new ExtensionError('transform_route_mismatch', `URL 不匹配转换配置: ${packet.url}`); } function serializeStructuredBody(value: unknown, format: BodyFormat): Uint8Array { if (format === 'form') { const form = new URLSearchParams(); for (const [key, item] of Object.entries(value as Record)) { if (Array.isArray(item)) item.forEach((entry) => form.append(key, stringValue(entry))); else form.append(key, stringValue(item)); } return encoder.encode(form.toString()); } return encoder.encode(JSON.stringify(value)); } export async function executeTransformDirection( profileId: string, directionName: 'request' | 'response', direction: BrowserTransformDirection, packet: BrowserTransformPacket, invoke: PageCallableInvoker, ): Promise { assertTransformDirection(direction); const started = performance.now(); const rawBody = decodeBase64(packet.bodyBase64); const headers = headerRecord(packet.headers); const parsed = parseBody(rawBody, headers); const context: TransformContext = { method: packet.method?.toUpperCase(), url: packet.url, statusCode: packet.statusCode, headers, query: queryRecord(packet.url), body: parsed.body, text: parsed.text, bodyBase64: packet.bodyBase64, }; const logicalInput = cloneJsonBody(context); const results = new Map(); const nodeDurations: Array<{ nodeId: string; durationMs: number }> = []; const nodeTrace: BrowserTransformExecution['nodeTrace'] = []; let outputBody = rawBody; let logicalBody = parsed.body; let outputUrl = packet.url; const setHeaders = new Map(); const removeHeaders = new Map(); for (const node of direction.nodes) { const nodeStarted = performance.now(); if (node.kind === 'context.read') { results.set(node.id, cloneJsonBody(readTransformValue(context, node.path))); } else if (node.kind === 'builtin') { results.set(node.id, executeBuiltin(node.operation, node.inputs.map((reference) => resolveReference(results, reference)), node.options)); } else if (node.kind === 'page.call') { const execution = await invoke(node.callableId, node.arguments.map((reference) => resolveReference(results, reference))); results.set(node.id, execution.value); } else { const value = resolveReference(results, node.source); const destination = node.destination.trim(); if (destination === 'body') { outputBody = bytesValue(value, node.encoding); logicalBody = value; } else if (destination.startsWith('body.')) { logicalBody = writeObjectPath(logicalBody, destination.slice(5), value); outputBody = serializeStructuredBody(logicalBody, parsed.format === 'form' ? 'form' : 'json'); } else if (destination.toLowerCase().startsWith('header.')) { const name = destination.slice(7).trim(); const normalized = name.toLowerCase(); const encoded = value === undefined || value === null ? undefined : node.encoding === 'base64' ? encodeBase64(bytesValue(value)) : stringValue(value); if (encoded === undefined) { removeHeaders.set(normalized, name); setHeaders.delete(normalized); } else { if (/[\r\n]/.test(encoded)) throw new ExtensionError('transform_output_invalid', `Header ${name} 的值包含换行`); setHeaders.set(normalized, { name, value: encoded }); removeHeaders.delete(normalized); } } else if (destination.startsWith('query.')) { const url = new URL(outputUrl); const key = destination.slice(6); if (value === undefined || value === null) url.searchParams.delete(key); else url.searchParams.set(key, stringValue(value)); outputUrl = url.toString(); } results.set(node.id, value); } const durationMs = Math.max(0, performance.now() - nodeStarted); nodeDurations.push({ nodeId: node.id, durationMs }); const inputRefs = node.kind === 'context.read' ? [node.path] : node.kind === 'builtin' ? node.inputs.map(referenceLabel) : node.kind === 'page.call' ? node.arguments.map(referenceLabel) : [referenceLabel(node.source)]; nodeTrace.push({ nodeId: node.id, kind: node.kind, name: node.name, inputRefs, output: summarizeTransformValue(results.get(node.id)), durationMs, }); } const fieldChanges: BrowserTransformExecution['fieldChanges'] = []; const differenceBudget: DifferenceBudget = { compared: 0, changes: 0 }; appendTransformChanges(context.body, logicalBody, 'body', fieldChanges, differenceBudget); appendTransformChanges(context.query, queryRecord(outputUrl), 'query', fieldChanges, differenceBudget); const headerDestinations = new Set(direction.nodes.flatMap((node) => ( node.kind === 'output.write' && node.destination.toLowerCase().startsWith('header.') ? [node.destination.slice(7).trim().toLowerCase()] : [] ))); for (const name of [...headerDestinations].sort()) { const before = context.headers[name]; const after = removeHeaders.has(name) ? undefined : setHeaders.get(name)?.value ?? before; appendTransformChanges(before, after, `header.${name}`, fieldChanges, differenceBudget); } return { profileId, direction: directionName, url: outputUrl, bodyBase64: encodeBase64(outputBody), setHeaders: [...setHeaders.values()], removeHeaders: [...removeHeaders.values()], logicalInput, logicalOutput: cloneJsonBody({ url: outputUrl, body: logicalBody, nodes: Object.fromEntries(results) }), nodeDurations, nodeTrace, fieldChanges, durationMs: Math.max(0, performance.now() - started), }; }