From 75f431a5c1c051be61f44382862f3abb77a8cf77 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:54:53 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E5=A4=A7?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E4=B8=8E=E8=B5=84=E6=BA=90=E7=9A=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=A4=A7=E5=B0=8F=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/custom_event_message.ts | 2 + packages/message/extension_message.ts | 6 ++ packages/message/message_queue.ts | 21 ++-- packages/message/message_size.test.ts | 77 +++++++++++++ packages/message/message_size.ts | 102 ++++++++++++++++++ packages/message/server.test.ts | 13 +++ packages/message/server.ts | 38 +++++-- packages/message/window_message.ts | 15 ++- src/app/repo/resource.ts | 44 ++++++++ .../service/offscreen/event_page_manager.ts | 4 + .../offscreen/external-access-connect.test.ts | 25 +++++ .../offscreen/external-access-connect.ts | 22 +++- src/app/service/service_worker/client.ts | 15 ++- .../external_access/source.test.ts | 13 ++- .../service_worker/external_access/source.ts | 51 +++++++-- .../service/service_worker/resource.test.ts | 60 +++++++++++ src/app/service/service_worker/resource.ts | 67 +++++++++++- .../ScriptEditor/tabs/ResourcePane.test.tsx | 62 ++++++++--- .../routes/ScriptEditor/tabs/ResourcePane.tsx | 86 +++++++++------ 19 files changed, 639 insertions(+), 84 deletions(-) create mode 100644 packages/message/message_size.test.ts create mode 100644 packages/message/message_size.ts diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index 63b33038a..e9865bf50 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -13,6 +13,7 @@ import { } from "@Packages/message/common"; import { ReadyWrap } from "@App/pkg/utils/ready-wrap"; import type { ScriptEnvTag } from "@Packages/message/consts"; +import { assertStructuredMessageSize } from "./message_size"; // 避免页面载入后改动 Map.prototype 导致消息传递失败 const relatedTargetMapMethods = { @@ -133,6 +134,7 @@ export class CustomEventMessage implements Message { nativeSend(detail: any) { if (!this.readyWrap.isReady) throw new Error("custom_event_message is not ready."); + assertStructuredMessageSize(detail, "CustomEvent.detail"); pageDispatchCustomEvent(this.sendFlag, detail); } diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index e0702c98f..3a8f3c05c 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -1,6 +1,7 @@ import EventEmitter from "eventemitter3"; import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessage, TMessageCommAction } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; +import { assertStructuredMessageSize } from "./message_size"; const listenerMgr = new EventEmitter(); // 单一管理器 @@ -9,6 +10,7 @@ export class ExtensionMessage implements Message { connect(data: TMessage): Promise { return new Promise((resolve) => { + assertStructuredMessageSize(data, "chrome.runtime.connect"); const con = chrome.runtime.connect(); con.postMessage(data); resolve(new ExtensionMessageConnect(con)); @@ -18,6 +20,7 @@ export class ExtensionMessage implements Message { // 发送消息 注意不进行回调的内存泄漏 sendMessage(data: TMessage): Promise { return new Promise((resolve: ((value: T) => void) | null) => { + assertStructuredMessageSize(data, "chrome.runtime.sendMessage"); chrome.runtime.sendMessage(data, (resp: T) => { const lastError = chrome.runtime.lastError; if (lastError) { @@ -188,6 +191,7 @@ export class ExtensionMessageConnect implements MessageConnect { // 無法 sendMessage 不应该屏蔽错误 throw new Error("Attempted to sendMessage on a disconnected port."); } + assertStructuredMessageSize(data, "chrome.runtime.Port.postMessage"); this.con.postMessage(data); } @@ -242,6 +246,7 @@ export class ExtensionContentMessageSend implements MessageSend { sendMessage(data: TMessage): Promise { return new Promise((resolve) => { + assertStructuredMessageSize(data, "chrome.tabs.sendMessage"); if (!this.options?.documentId && !this.options?.frameId) { // 发送给指定的tab chrome.tabs.sendMessage(this.tabId, data, (resp: T) => { @@ -267,6 +272,7 @@ export class ExtensionContentMessageSend implements MessageSend { connect(data: TMessage): Promise { return new Promise((resolve) => { + assertStructuredMessageSize(data, "chrome.tabs.connect"); const con = chrome.tabs.connect(this.tabId, this.options); con.postMessage(data); resolve(new ExtensionMessageConnect(con)); diff --git a/packages/message/message_queue.ts b/packages/message/message_queue.ts index b681b0c73..e6a19fb8b 100644 --- a/packages/message/message_queue.ts +++ b/packages/message/message_queue.ts @@ -2,6 +2,7 @@ import EventEmitter from "eventemitter3"; import LoggerCore from "@App/app/logger/core"; import { type TMessage } from "./types"; import type { SystemConfigKey, SystemConfigValueType } from "@App/pkg/config/config"; +import { assertStructuredMessageSize, MessageSizeError } from "./message_size"; export type TKeyValue = { key: T; @@ -79,12 +80,13 @@ export class MessageQueue implements IMessageQueue { // "Could not establish connection. Receiving end does not exist."——不接住就会变成 // 未处理的 Promise rejection。publish 本身是"广播给任何在监听的人",无人监听应静默忽略。 const messageQueueLogger = LoggerCore.getInstance().logger({ service: "messageQueue" }); - chrome.runtime - .sendMessage({ - msgQueue: topic, - data: { action: "message", message }, - }) - .catch((e) => { + const outbound = { + msgQueue: topic, + data: { action: "message", message }, + }; + try { + assertStructuredMessageSize(outbound, "message-queue.publish"); + chrome.runtime.sendMessage(outbound).catch((e) => { const msg = JSON.stringify(e?.message || e); if (msg.includes("Could not establish connection. Receiving end does not exist.")) { messageQueueLogger.debug("No target audience for .publish", { msg }); @@ -92,6 +94,13 @@ export class MessageQueue implements IMessageQueue { messageQueueLogger.error("Unable to execute runtime.sendMessage for .publish", { msg }); } }); + } catch (e) { + if (e instanceof MessageSizeError) { + messageQueueLogger.error("Unable to execute runtime.sendMessage for .publish", { error: e.message }); + } else { + throw e; + } + } this.EE.emit(topic, message); //@ts-ignore messageQueueLogger.trace("publish", { topic, message }); diff --git a/packages/message/message_size.test.ts b/packages/message/message_size.test.ts new file mode 100644 index 000000000..99ed37c8c --- /dev/null +++ b/packages/message/message_size.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { + MAX_EXTENSION_MESSAGE_BYTES, + MessageSizeError, + assertMessageSize, + measureMessageBytes, + measureStructuredMessageBytes, + utf8ByteLength, +} from "./message_size"; +import { ExtensionMessage } from "./extension_message"; + +describe("message size accounting", () => { + it("counts UTF-8 bytes after JSON escaping", () => { + const value = { data: '你好"\\😀' }; + const serialized = JSON.stringify(value); + + expect(utf8ByteLength(serialized)).toBe(new TextEncoder().encode(serialized).byteLength); + expect(measureMessageBytes(value)).toBe(utf8ByteLength(serialized)); + expect(measureMessageBytes(value)).toBeGreaterThan(value.data.length); + }); + + it("measures the complete envelope and accepts exact limits", () => { + const envelope = { messageId: "m1", type: "sendMessage", data: { action: "resource", data: "x" } }; + const bytes = measureMessageBytes(envelope); + + expect(() => assertMessageSize(envelope, "test-channel", bytes)).not.toThrow(); + expect(() => assertMessageSize(envelope, "test-channel", bytes - 1)).toThrow( + new MessageSizeError("test-channel", bytes, bytes - 1).message + ); + }); + + it("includes base64 expansion and reports deterministic over-limit diagnostics", () => { + const envelope = { + msgQueue: "backup", + data: { action: "message", message: { base64: "data:image/png;base64," + "A0==" } }, + }; + const bytes = measureMessageBytes(envelope); + const error = (() => { + try { + assertMessageSize(envelope, "message-queue", bytes - 1); + return undefined; + } catch (e) { + return e; + } + })(); + + expect(error).toBeInstanceOf(MessageSizeError); + expect((error as MessageSizeError).message).toBe( + `message-queue message exceeds limit: ${bytes} bytes (limit ${bytes - 1} bytes)` + ); + }); + + it("counts binary payloads on structured-clone channels", () => { + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "application/octet-stream" }); + const envelope = { messageId: "m1", type: "sendMessage", data: { blob } }; + + expect(measureStructuredMessageBytes(envelope)).toBe(measureMessageBytes(envelope) + blob.size); + }); + + it("counts binary values nested in Map and Set structured-clone payloads", () => { + const mapBlob = new Blob([new Uint8Array([1, 2])]); + const setBlob = new Blob([new Uint8Array([3, 4, 5])]); + const envelope = { data: new Map([["blob", mapBlob]]), values: new Set([setBlob]) }; + + expect(measureStructuredMessageBytes(envelope)).toBe(measureMessageBytes(envelope) + mapBlob.size + setBlob.size); + }); + + it("rejects an oversized extension message before calling chrome.runtime", async () => { + const sendMessage = vi.spyOn(chrome.runtime, "sendMessage"); + const message = new ExtensionMessage(); + + await expect( + message.sendMessage({ action: "large", data: "x".repeat(MAX_EXTENSION_MESSAGE_BYTES) }) + ).rejects.toBeInstanceOf(MessageSizeError); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/message/message_size.ts b/packages/message/message_size.ts new file mode 100644 index 000000000..c904f97f6 --- /dev/null +++ b/packages/message/message_size.ts @@ -0,0 +1,102 @@ +/** Chrome's documented per-message runtime limit. */ +export const MAX_EXTENSION_MESSAGE_BYTES = 64 * 1024 * 1024; + +export class MessageSizeError extends Error { + constructor( + public readonly operation: string, + public readonly actualBytes: number, + public readonly limitBytes: number, + reason?: string + ) { + super( + reason + ? `${operation} message cannot be serialized: ${reason}` + : `${operation} message exceeds limit: ${actualBytes} bytes (limit ${limitBytes} bytes)` + ); + this.name = "MessageSizeError"; + } +} + +export function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +export function serializeMessage(value: unknown): string { + const serialized = JSON.stringify(value); + if (serialized === undefined) { + throw new TypeError("JSON.stringify returned undefined"); + } + return serialized; +} + +export function measureMessageBytes(value: unknown): number { + return utf8ByteLength(serializeMessage(value)); +} + +export function measureStructuredMessageBytes(value: unknown): number { + return measureMessageBytes(value) + measureStructuredBinaryBytes(value); +} + +export function assertMessageSize(value: unknown, operation: string, limitBytes = MAX_EXTENSION_MESSAGE_BYTES): number { + let bytes: number; + try { + bytes = measureMessageBytes(value); + } catch (error) { + throw new MessageSizeError( + operation, + Number.NaN, + limitBytes, + error instanceof Error ? error.message : String(error) + ); + } + if (bytes > limitBytes) { + throw new MessageSizeError(operation, bytes, limitBytes); + } + return bytes; +} + +export function assertStructuredMessageSize( + value: unknown, + operation: string, + limitBytes = MAX_EXTENSION_MESSAGE_BYTES +): number { + let bytes: number; + try { + bytes = measureStructuredMessageBytes(value); + } catch (error) { + throw new MessageSizeError( + operation, + Number.NaN, + limitBytes, + error instanceof Error ? error.message : String(error) + ); + } + if (bytes > limitBytes) { + throw new MessageSizeError(operation, bytes, limitBytes); + } + return bytes; +} + +function measureStructuredBinaryBytes(value: unknown, seen = new WeakSet()): number { + if (typeof value !== "object" || value === null || seen.has(value)) return 0; + seen.add(value); + + if (typeof Blob !== "undefined" && value instanceof Blob) return value.size; + if (value instanceof ArrayBuffer) return value.byteLength; + if (ArrayBuffer.isView(value)) return Object.keys(value).length === 0 ? value.byteLength : 0; + + if (value instanceof Map) { + let total = 0; + for (const [key, child] of value) { + total += measureStructuredBinaryBytes(key, seen) + measureStructuredBinaryBytes(child, seen); + } + return total; + } + if (value instanceof Set) { + let total = 0; + for (const child of value) total += measureStructuredBinaryBytes(child, seen); + return total; + } + + return Object.values(value).reduce((total, child) => total + measureStructuredBinaryBytes(child, seen), 0); +} diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b7b1ff007..ca9a5b2fe 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -3,6 +3,7 @@ import { GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } import { CustomEventMessage } from "./custom_event_message"; import type { MessageConnect, RuntimeMessageSender } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; +import { MAX_EXTENSION_MESSAGE_BYTES } from "./message_size"; let inboundMessage: CustomEventMessage; let outboundMessage: CustomEventMessage; @@ -459,6 +460,18 @@ describe("Server", () => { "level1-after", ]); }); + + it("过大的响应不会再次触发传输异常,而会返回有界诊断", async () => { + server.on("oversized-response", () => "x".repeat(MAX_EXTENSION_MESSAGE_BYTES)); + + const response = await client.sendMessage({ + action: "api/oversized-response", + data: {}, + }); + + expect(response.code).toBe(-1); + expect(response.message).toContain("message-server/oversized-response message exceeds limit"); + }); }); describe("IGetSender 功能测试", () => { diff --git a/packages/message/server.ts b/packages/message/server.ts index 3df109f80..6e8679f7e 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -3,6 +3,7 @@ import LoggerCore from "@App/app/logger/core"; import { connect, sendMessage } from "./client"; import { ExtensionMessageConnect } from "./extension_message"; import Logger from "@App/app/logger/logger"; +import { assertStructuredMessageSize, MessageSizeError } from "./message_size"; export const enum GetSenderType { CONNECT = 1, @@ -170,6 +171,17 @@ export class Server { this.apiFunctionMap.set(name, func); } + private sendConnectResponse(con: MessageConnect, response: TMessage, action: string) { + try { + assertStructuredMessageSize(response, `message-server/${action}`); + con.sendMessage(response); + } catch (e) { + if (!(e instanceof MessageSizeError)) throw e; + this.logger.error("message-server response exceeded transport limit", Logger.E(e)); + con.sendMessage({ code: -1, message: e.message }); + } + } + private connectHandle(msg: string, params: any, con: MessageConnect) { const func = this.apiFunctionMap.get(msg); if (func) { @@ -178,15 +190,15 @@ export class Server { if (ret instanceof Promise) { ret .then((data) => { - data && con.sendMessage({ code: 0, data }); + data && this.sendConnectResponse(con, { code: 0, data }, msg); }) .catch((e: Error) => { - con.sendMessage({ code: -1, message: formatErrorToClient(e) }); + this.sendConnectResponse(con, { code: -1, message: formatErrorToClient(e) }, msg); this.logger.error("connectHandle error", Logger.E(e)); }); return true; } else { - con.sendMessage({ code: 0, data: ret }); + this.sendConnectResponse(con, { code: 0, data: ret }, msg); } } return true; @@ -200,6 +212,16 @@ export class Server { sender: RuntimeMessageSender ) { const func = this.apiFunctionMap.get(action); + const respond = (response: any) => { + try { + assertStructuredMessageSize(response, `message-server/${action}`); + sendResponse(response); + } catch (e) { + if (!(e instanceof MessageSizeError)) throw e; + this.logger.error("message-server response exceeded transport limit", Logger.E(e)); + sendResponse({ code: -1, message: e.message }); + } + }; if (func) { try { const ret = func(params, new SenderRuntime(sender)); @@ -207,25 +229,25 @@ export class Server { ret .then((data) => { try { - sendResponse({ code: 0, data }); + respond({ code: 0, data }); } catch (e: any) { this.logger.error("sendResponse error", Logger.E(e)); } }) .catch((e: Error) => { - sendResponse({ code: -1, message: formatErrorToClient(e) }); + respond({ code: -1, message: formatErrorToClient(e) }); this.logger.error("messageHandle error", Logger.E(e)); }); return true; } else { - sendResponse({ code: 0, data: ret }); + respond({ code: 0, data: ret }); } } catch (e: any) { - sendResponse({ code: -1, message: formatErrorToClient(e) }); + respond({ code: -1, message: formatErrorToClient(e) }); this.logger.error("messageHandle error", Logger.E(e)); } } else { - sendResponse({ code: -1, message: "no such api " + action }); + respond({ code: -1, message: "no such api " + action }); this.logger.error("no such api", { action: action }); } } diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 9946bd089..b13d28577 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -8,6 +8,7 @@ import type { } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; import EventEmitter from "eventemitter3"; +import { assertStructuredMessageSize } from "./message_size"; const listenerMgr = new EventEmitter(); // 单一管理器 @@ -21,6 +22,7 @@ class WindowPostMessage implements PostMessage { constructor(private target: Window) {} postMessage(message: T): void { + assertStructuredMessageSize(message, "window.postMessage"); this.target.postMessage(message, "*"); } } @@ -117,6 +119,7 @@ export class WindowMessage implements Message { type: "connect", data, }; + assertStructuredMessageSize(body, "window.postMessage"); const target = this.getTarget(); target.postMessage(body, "*"); // 使用 WindowPostMessage 包装,确保后续 sendMessage 也带 "*" targetOrigin @@ -140,6 +143,7 @@ export class WindowMessage implements Message { type: "sendMessage", data, }; + assertStructuredMessageSize(body, "window.postMessage"); const eventId = `response:${messageId}`; this.EE.addListener(eventId, (body: WindowMessageBody) => { this.EE.removeAllListeners(eventId); @@ -192,6 +196,7 @@ export class WindowMessageConnect implements MessageConnect { type: "connectMessage", data, }; + assertStructuredMessageSize(body, "window.postMessage"); this.target.postMessage(body); } @@ -274,6 +279,7 @@ export class ServiceWorkerMessageSend implements Message { type: "respMessage", data: resp, }; + assertStructuredMessageSize(body, "serviceWorker.postMessage"); source.postMessage(body); }, {} as RuntimeMessageSender @@ -306,6 +312,7 @@ export class ServiceWorkerMessageSend implements Message { type: "connect", data, }; + assertStructuredMessageSize(body, "serviceWorker.postMessage"); this.target!.postMessage(body); return new WindowMessageConnect(body.messageId, this.EE, this.target!); } @@ -320,6 +327,7 @@ export class ServiceWorkerMessageSend implements Message { type: "sendMessage", data, }; + assertStructuredMessageSize(body, "serviceWorker.postMessage"); const eventId = `response:${messageId}`; this.EE.addListener(eventId, (body: WindowMessageBody) => { this.EE.removeAllListeners(eventId); @@ -363,7 +371,11 @@ export class ServiceWorkerClientMessage implements Message { this.EE.emit( "message", data.data, - (resp: any) => source.postMessage({ messageId: data.messageId, type: "respMessage", data: resp }), + (resp: any) => { + const body: WindowMessageBody = { messageId: data.messageId, type: "respMessage", data: resp }; + assertStructuredMessageSize(body, "serviceWorker.postMessage"); + source.postMessage(body); + }, {} as RuntimeMessageSender ); } else if (data.type === "connect" && source) { @@ -386,6 +398,7 @@ export class ServiceWorkerClientMessage implements Message { } private postToServiceWorker(message: any) { + assertStructuredMessageSize(message, "serviceWorker.postMessage"); if (this.sw) { this.sw.postMessage(message); } else { diff --git a/src/app/repo/resource.ts b/src/app/repo/resource.ts index c705d35c8..5a2612ec5 100644 --- a/src/app/repo/resource.ts +++ b/src/app/repo/resource.ts @@ -16,6 +16,50 @@ export interface Resource { updatetime?: number; } +export interface ResourceListItem { + key: string; + url: string; + type: ResourceType; + contentType: string; + byteSize: number; +} + +export interface ResourceListPage { + items: ResourceListItem[]; + offset: number; + limit: number; + total: number; + nextOffset?: number; +} + +export interface ResourceChunkRequest { + uuid: string; + url: string; + offset: number; + length: number; +} + +export interface ResourceChunk { + url: string; + offset: number; + length: number; + total: number; + /** Base64-encoded bytes without a data-URI prefix. */ + base64: string; +} + +export const RESOURCE_LIST_PAGE_SIZE = 100; +export const RESOURCE_CHUNK_BYTES = 512 * 1024; + +export function getResourceByteSize(resource: { content: string; base64?: string }): number { + if (resource.base64) { + const comma = resource.base64.indexOf(","); + const encoded = comma === -1 ? resource.base64 : resource.base64.slice(comma + 1); + return atob(encoded).length; + } + return new TextEncoder().encode(resource.content).byteLength; +} + export interface ResourceHash { md5: string; sha1: string; diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index ac019c1ea..66982c76f 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -12,6 +12,7 @@ import EventEmitter from "eventemitter3"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { ServiceWorkerClient } from "../service_worker/client"; import { BackgroundEnvManagerBase } from "./base"; +import { assertStructuredMessageSize } from "@Packages/message/message_size"; class InProcessMessageConnect implements MessageConnect { private messages = new EventEmitter(); @@ -28,6 +29,7 @@ class InProcessMessageConnect implements MessageConnect { sendMessage(data: TMessage): void { if (!this.disconnected) { + assertStructuredMessageSize(data, "event-page.port.postMessage"); this.peer?.messages.emit("message", data); } } @@ -57,6 +59,7 @@ export class InProcessMessage implements Message, MessageSend { private events = new EventEmitter(); connect(data: TMessage): Promise { + assertStructuredMessageSize(data, "event-page.connect"); const client = new InProcessMessageConnect(); const server = new InProcessMessageConnect(); client.peer = server; @@ -68,6 +71,7 @@ export class InProcessMessage implements Message, MessageSend { } sendMessage(data: TMessage): Promise { + assertStructuredMessageSize(data, "event-page.sendMessage"); return new Promise((resolve) => { this.events.emit("message", data, resolve, {} as RuntimeMessageSender); }); diff --git a/src/app/service/offscreen/external-access-connect.test.ts b/src/app/service/offscreen/external-access-connect.test.ts index 990dc0a72..4a54fd561 100644 --- a/src/app/service/offscreen/external-access-connect.test.ts +++ b/src/app/service/offscreen/external-access-connect.test.ts @@ -405,6 +405,31 @@ describe("ExternalAccessConnect", () => { (externalAccessConnect as any).sendEnvelope({ jsonrpc: "2.0", id: "r1", result: {} }); expect(ws.sentMessages.length).toBe(0); }); + + it("出站 WebSocket frame 超过协议上限时被丢弃", async () => { + const ws = await completeSessionHandshake(); + const sentCount = ws.sentMessages.length; + (externalAccessConnect as any).sendEnvelope({ + jsonrpc: "2.0", + id: "too-large", + result: { source: "x".repeat(LIMITS.maxFrameBytes) }, + }); + + expect(ws.sentMessages.length).toBe(sentCount); + }); + + it("入站 WebSocket frame 超过协议上限时关闭连接", async () => { + const ws = await completeSessionHandshake(); + const closeSpy = vi.spyOn(ws, "close"); + ws.simulateMessage({ + jsonrpc: "2.0", + method: "scripts.list", + params: { input: "x".repeat(LIMITS.maxFrameBytes) }, + }); + + await Promise.resolve(); + expect(closeSpy).toHaveBeenCalled(); + }); }); // ──────────────────────────────────────────────── diff --git a/src/app/service/offscreen/external-access-connect.ts b/src/app/service/offscreen/external-access-connect.ts index 8203abc17..16f954145 100644 --- a/src/app/service/offscreen/external-access-connect.ts +++ b/src/app/service/offscreen/external-access-connect.ts @@ -5,6 +5,7 @@ import type { MessageSend } from "@Packages/message/types"; import { ExternalAccessConnectRelayClient } from "../service_worker/client"; import { CRYPTO, LIMITS } from "../service_worker/external_access/generated/protocol.generated"; import { decodeWireEnvelope } from "../service_worker/external_access/protocol-wire"; +import { utf8ByteLength } from "@Packages/message/message_size"; import type { AuthChallengePayload, AuthMode, @@ -250,6 +251,15 @@ export class ExternalAccessConnect { private async handleMessage(ev: MessageEvent, sessionEpoch: number): Promise { if (sessionEpoch !== this.epoch) return; + if (typeof ev.data !== "string" || utf8ByteLength(ev.data) > LIMITS.maxFrameBytes) { + this.logger.warn("Dropped inbound WebSocket frame over limit", { + bytes: typeof ev.data === "string" ? utf8ByteLength(ev.data) : undefined, + limit: LIMITS.maxFrameBytes, + }); + this.ws?.close(); + return; + } + let envelope: WSEnvelope; try { envelope = decodeWireEnvelope(ev.data as string); @@ -356,7 +366,17 @@ export class ExternalAccessConnect { private rawSend(envelope: WSEnvelope): void { if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(envelope)); + const frame = JSON.stringify(envelope); + const bytes = utf8ByteLength(frame); + if (bytes > LIMITS.maxFrameBytes) { + this.logger.warn("Dropped outbound WebSocket frame over limit", { + method: envelope.method, + bytes, + limit: LIMITS.maxFrameBytes, + }); + return; + } + this.ws.send(frame); } } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 07ea7b6fb..b58a27912 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -1,5 +1,10 @@ import type { Script, ScriptCode, ScriptRunResource, TClientPageLoadInfo } from "@App/app/repo/scripts"; -import { type Resource } from "@App/app/repo/resource"; +import { + RESOURCE_LIST_PAGE_SIZE, + type ResourceChunk, + type ResourceChunkRequest, + type ResourceListPage, +} from "@App/app/repo/resource"; import { type Subscribe } from "@App/app/repo/subscribe"; import { type Logger } from "@App/app/repo/logger"; import { type Permission } from "@App/app/repo/permission"; @@ -298,8 +303,12 @@ export class ResourceClient extends Client { super(msgSender, "serviceWorker/resource"); } - getScriptResources(script: Script): Promise<{ [key: string]: Resource }> { - return this.doThrow("getScriptResources", script); + getScriptResources(script: Script, offset = 0, limit = RESOURCE_LIST_PAGE_SIZE): Promise { + return this.doThrow("getScriptResources", { script, offset, limit }); + } + + getResourceChunk(params: ResourceChunkRequest): Promise { + return this.doThrow("getResourceChunk", params); } deleteResource(url: string) { diff --git a/src/app/service/service_worker/external_access/source.test.ts b/src/app/service/service_worker/external_access/source.test.ts index 8ce8ee351..bedd460e1 100644 --- a/src/app/service/service_worker/external_access/source.test.ts +++ b/src/app/service/service_worker/external_access/source.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { sliceLines, grepLines, applyTextEdits } from "./source"; +import { sliceLines, grepLines, applyTextEdits, MAX_GREP_RESULT_BYTES } from "./source"; import { ExternalAccessBridgeError } from "./errors"; describe("sliceLines(scripts.source.get 的行开窗,1-based 闭区间)", () => { @@ -136,6 +136,17 @@ describe("grepLines(scripts.source.grep 的逐行匹配,不复用 stringMatc expect(result.truncated).toBe(true); }); + it("匹配结果总字节数受上限约束,超长命中不会在 service worker 中累积", () => { + const result = grepLines(`hit ${"x".repeat(MAX_GREP_RESULT_BYTES)}`, "hit"); + + expect(result.totalMatches).toBe(1); + expect(result.matches).toEqual([]); + expect(result.truncated).toBe(true); + expect(new TextEncoder().encode(JSON.stringify(result.matches)).byteLength).toBeLessThanOrEqual( + MAX_GREP_RESULT_BYTES + ); + }); + it("未触发 maxMatches 截断时 truncated 为 false", () => { const result = grepLines(code, "import"); expect(result.truncated).toBe(false); diff --git a/src/app/service/service_worker/external_access/source.ts b/src/app/service/service_worker/external_access/source.ts index 67624b5ba..7e12806b1 100644 --- a/src/app/service/service_worker/external_access/source.ts +++ b/src/app/service/service_worker/external_access/source.ts @@ -8,6 +8,10 @@ import type { ScriptSource, ScriptSourceGrepMatch, ScriptSourceGrepResult } from // slice (design §4.1), not the underlying file, so a line window can be pulled out of an // oversized script while a full read keeps behaving exactly as before. export const MAX_SOURCE_BYTES = 2 * 1024 * 1024; +// Keep grep responses well below the WebSocket frame cap after JSON-RPC envelopes and UTF-8 +// encoding are added. A bounded result also prevents a set of long matching lines from building +// an unbounded response in the service worker. +export const MAX_GREP_RESULT_BYTES = 512 * 1024; export interface SlicedLines { code: string; @@ -128,6 +132,10 @@ export interface GrepLinesResult { totalLines: number; } +function serializedBytes(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} + // Compiling the user's pattern is itself a validation step, so acceptance-time checking and the // actual scan share it rather than each spelling out their own try/catch. function compileGrepPattern(query: string, mode: "text" | "regex", ignoreCase: boolean): RegExp | undefined { @@ -194,6 +202,7 @@ export function grepLines( const totalLines = lines.length; const matches: ScriptSourceGrepMatch[] = []; let totalMatches = 0; + let resultTruncated = false; const skippedLongLines = 0; const startedAt = clock(); @@ -211,16 +220,27 @@ export function grepLines( totalMatches++; if (matches.length < maxMatches) { - matches.push({ + const match = { lineNumber: i + 1, line, before: lines.slice(Math.max(0, i - contextLines), i), after: lines.slice(i + 1, i + 1 + contextLines), - }); + }; + if (serializedBytes([...matches, match]) <= MAX_GREP_RESULT_BYTES) { + matches.push(match); + } else { + resultTruncated = true; + } } } - return { matches, totalMatches, truncated: totalMatches > matches.length, skippedLongLines, totalLines }; + return { + matches, + totalMatches, + truncated: resultTruncated || totalMatches > matches.length, + skippedLongLines, + totalLines, + }; } /** @@ -240,19 +260,30 @@ export async function grepScriptSource( const scriptCode = await scriptCodeDAO.get(uuid); if (!scriptCode) throw new ExternalAccessBridgeError("NOT_FOUND", "script source not found"); - const result = grepLines(scriptCode.code, query, options); - return { + const grepResult = grepLines(scriptCode.code, query, options); + const result: ScriptSourceGrepResult = { uuid: script.uuid, name: script.name, version: script.metadata.version?.[0], - matches: result.matches, - totalMatches: result.totalMatches, - truncated: result.truncated, - skippedLongLines: result.skippedLongLines, - totalLines: result.totalLines, + matches: grepResult.matches, + totalMatches: grepResult.totalMatches, + truncated: grepResult.truncated, + skippedLongLines: grepResult.skippedLongLines, + totalLines: grepResult.totalLines, sha256: sha256OfText(scriptCode.code), contentTrust: "untrusted-user-script-source", }; + if (serializedBytes(result) <= MAX_GREP_RESULT_BYTES) return result; + + const matches = [...result.matches]; + while (matches.length > 0 && serializedBytes({ ...result, matches }) > MAX_GREP_RESULT_BYTES) { + matches.pop(); + } + const bounded = { ...result, matches, truncated: true }; + if (serializedBytes(bounded) > MAX_GREP_RESULT_BYTES) { + throw new ExternalAccessBridgeError("PAYLOAD_TOO_LARGE", "grep result exceeds 512 KiB"); + } + return bounded; } export interface TextEdit { diff --git a/src/app/service/service_worker/resource.test.ts b/src/app/service/service_worker/resource.test.ts index c9fc1f176..35eafa797 100644 --- a/src/app/service/service_worker/resource.test.ts +++ b/src/app/service/service_worker/resource.test.ts @@ -412,6 +412,66 @@ describe("ResourceService - getResourceByTypes", () => { }); }); +describe("ResourceService - resource list and chunks", () => { + let service: ResourceService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ResourceService({} as Group, {} as IMessageQueue); + }); + + it("returns paged resource metadata without transferring content or base64", async () => { + const resource = { ...resourceModel("https://example.com/data.txt", "text"), content: "你好", base64: "" }; + vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({ + alias: resource, + }); + + const page = await service.getScriptResourcePage(normalScript("script-page", {}), 0, 1); + + expect(page).toEqual({ + items: [ + { + key: "alias", + url: resource.url, + type: resource.type, + contentType: resource.contentType, + byteSize: new TextEncoder().encode(resource.content).byteLength, + }, + ], + offset: 0, + limit: 1, + total: 1, + nextOffset: undefined, + }); + expect(page.items[0]).not.toHaveProperty("content"); + expect(page.items[0]).not.toHaveProperty("base64"); + }); + + it("returns a bounded UTF-8 byte range as raw base64", async () => { + const resource = { ...resourceModel("https://example.com/data.txt", "text"), content: "你好abc", base64: "" }; + vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource); + + const chunk = await service.getResourceChunk({ + uuid: "old-script", + url: resource.url, + offset: 1, + length: 4, + }); + + expect(chunk).toMatchObject({ url: resource.url, offset: 1, length: 4, total: 9 }); + expect([...Uint8Array.from(atob(chunk.base64), (char) => char.charCodeAt(0))]).toEqual([0xbd, 0xa0, 0xe5, 0xa5]); + }); + + it("does not expose chunks to a script that does not own the resource", async () => { + const resource = resourceModel("https://example.com/private.txt", "private"); + vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource); + + await expect( + service.getResourceChunk({ uuid: "other-script", url: resource.url, offset: 0, length: 1 }) + ).rejects.toThrow("resource not found"); + }); +}); + describe("ResourceService - updateResourceByTypes", () => { let service: ResourceService; diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 5b1110e07..4ca60c9b9 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -1,6 +1,16 @@ import LoggerCore from "@App/app/logger/core"; import Logger from "@App/app/logger/logger"; -import type { Resource, ResourceHash, ResourceType } from "@App/app/repo/resource"; +import { + getResourceByteSize, + RESOURCE_CHUNK_BYTES, + RESOURCE_LIST_PAGE_SIZE, + type Resource, + type ResourceChunk, + type ResourceChunkRequest, + type ResourceHash, + type ResourceListPage, + type ResourceType, +} from "@App/app/repo/resource"; import { ResourceDAO } from "@App/app/repo/resource"; import type { Script, ScriptResource, ScriptResourceByType } from "@App/app/repo/scripts"; import { type IMessageQueue } from "@Packages/message/message_queue"; @@ -459,12 +469,63 @@ export class ResourceService { return await this.resourceDAO.save(res); } - requestGetScriptResources(script: Script): Promise { - return this.getScriptResourceValue(script); + async getScriptResourcePage(script: Script, offset = 0, limit = RESOURCE_LIST_PAGE_SIZE): Promise { + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new Error("resource list offset must be a non-negative integer"); + } + if (!Number.isSafeInteger(limit) || limit < 1 || limit > RESOURCE_LIST_PAGE_SIZE) { + throw new Error(`resource list limit must be between 1 and ${RESOURCE_LIST_PAGE_SIZE}`); + } + + const resources = await this.getScriptResourceValue(script); + const entries = Object.entries(resources); + const items = entries.slice(offset, offset + limit).map(([key, resource]) => ({ + key, + url: resource.url, + type: resource.type, + contentType: resource.contentType, + byteSize: getResourceByteSize(resource), + })); + const nextOffset = offset + items.length < entries.length ? offset + items.length : undefined; + return { items, offset, limit, total: entries.length, nextOffset }; + } + + async getResourceChunk(params: ResourceChunkRequest): Promise { + const { uuid, url, offset, length } = params; + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new Error("resource chunk offset must be a non-negative integer"); + } + if (!Number.isSafeInteger(length) || length < 1 || length > RESOURCE_CHUNK_BYTES) { + throw new Error(`resource chunk length must be between 1 and ${RESOURCE_CHUNK_BYTES}`); + } + + const resource = await this.resourceDAO.get(url); + if (!resource || !resource.link[uuid]) { + throw new Error("resource not found"); + } + const source = resource.base64 + ? base64ToBlob(resource.base64) + : new Blob([resource.content], { type: resource.contentType }); + const total = source.size; + const chunk = source.slice(offset, Math.min(offset + length, total), resource.contentType); + const dataUri = await blobToBase64(chunk); + const comma = dataUri.indexOf(","); + return { + url, + offset, + length: chunk.size, + total, + base64: comma === -1 ? dataUri : dataUri.slice(comma + 1), + }; + } + + requestGetScriptResources(params: { script: Script; offset?: number; limit?: number }): Promise { + return this.getScriptResourcePage(params.script, params.offset, params.limit); } init() { this.group.on("getScriptResources", this.requestGetScriptResources.bind(this)); + this.group.on("getResourceChunk", this.getResourceChunk.bind(this)); this.group.on("deleteResource", this.deleteResource.bind(this)); // 删除相关资源 diff --git a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx index 63cb5477d..44e852a82 100644 --- a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx +++ b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx @@ -6,41 +6,51 @@ import { initTestLanguage } from "@Tests/initTestLanguage"; import { renderWithTooltip as render } from "@Tests/renderWithTooltip"; // 资源数据走后台消息,统一打桩;用 hoisted 以便在 vi.mock 工厂内引用 -const { fetchScript, getScriptResources, deleteResource } = vi.hoisted(() => ({ +const { fetchScript, getScriptResources, getResourceChunk, deleteResource } = vi.hoisted(() => ({ fetchScript: vi.fn(), getScriptResources: vi.fn(), + getResourceChunk: vi.fn(), deleteResource: vi.fn(), })); vi.mock("@App/pages/store/features/script", () => ({ fetchScript, - resourceClient: { getScriptResources, deleteResource }, + resourceClient: { getScriptResources, getResourceChunk, deleteResource }, })); import ResourcePane, { invalidateResourcePane, usePreloadResourcePane } from "./ResourcePane"; -const sampleResources = () => ({ - "https://cdn.test/jquery.min.js": { +const sampleResources = () => [ + { + key: "https://cdn.test/jquery.min.js", url: "https://cdn.test/jquery.min.js", type: "require", contentType: "application/javascript", - content: "var a=1;", - base64: "", + byteSize: 8, }, - "https://cdn.test/theme.css": { + { + key: "https://cdn.test/theme.css", url: "https://cdn.test/theme.css", type: "resource", contentType: "text/css", - content: "body{}", - base64: "", + byteSize: 6, }, -}); +]; + +const samplePage = () => ({ items: sampleResources(), offset: 0, limit: 100, total: 2 }); beforeAll(() => initTestLanguage("zh-CN")); beforeEach(() => { vi.clearAllMocks(); fetchScript.mockResolvedValue({ uuid: "u1", name: "脚本A" }); - getScriptResources.mockResolvedValue(sampleResources()); + getScriptResources.mockResolvedValue(samplePage()); + getResourceChunk.mockResolvedValue({ + url: "https://cdn.test/jquery.min.js", + offset: 0, + length: 8, + total: 8, + base64: "dmFyIGE9MTs=", + }); deleteResource.mockResolvedValue(undefined); }); afterEach(() => { @@ -59,6 +69,24 @@ describe("ResourcePane 资源面板", () => { expect(screen.getByText("application/javascript")).toBeInTheDocument(); }); + it("下载应按元数据请求脚本绑定的资源块", async () => { + const downloadSpy = vi.spyOn(chrome.downloads, "download").mockResolvedValue(undefined); + render(); + await screen.findByText("jquery.min.js"); + + fireEvent.click(screen.getAllByLabelText(t("download"))[0]); + + await waitFor(() => + expect(getResourceChunk).toHaveBeenCalledWith({ + uuid: "u1", + url: "https://cdn.test/jquery.min.js", + offset: 0, + length: 8, + }) + ); + expect(downloadSpy).toHaveBeenCalledWith(expect.objectContaining({ filename: "jquery.min.js", saveAs: true })); + }); + it("行内删除应二次确认后才调用 deleteResource 并移除该行", async () => { render(); await screen.findByText("jquery.min.js"); @@ -90,7 +118,7 @@ describe("ResourcePane 资源面板", () => { }); it("无资源时应展示空状态", async () => { - getScriptResources.mockResolvedValue({}); + getScriptResources.mockResolvedValue({ items: [], offset: 0, limit: 100, total: 0 }); render(); expect(await screen.findByText(t("no_data"))).toBeInTheDocument(); }); @@ -108,7 +136,7 @@ describe("ResourcePane 资源面板", () => { it("缓存失效后应重新加载同一脚本的资源", async () => { render(); await screen.findByText("jquery.min.js"); - getScriptResources.mockResolvedValue({}); + getScriptResources.mockResolvedValue({ items: [], offset: 0, limit: 100, total: 0 }); invalidateResourcePane("u1"); @@ -154,10 +182,10 @@ describe("ResourcePane 资源面板", () => { }); it("预加载取消不应展示错误", async () => { - let resolveFirst!: (resources: ReturnType) => void; + let resolveFirst!: (resources: ReturnType) => void; getScriptResources .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) - .mockResolvedValueOnce(sampleResources()); + .mockResolvedValueOnce(samplePage()); const toastError = vi.spyOn(notify, "error"); const { rerender } = renderHook(({ uuid }) => usePreloadResourcePane(uuid), { @@ -166,7 +194,7 @@ describe("ResourcePane 资源面板", () => { await waitFor(() => expect(getScriptResources).toHaveBeenCalledTimes(1)); rerender({ uuid: "u2" }); await waitFor(() => expect(getScriptResources).toHaveBeenCalledTimes(2)); - await act(async () => resolveFirst(sampleResources())); + await act(async () => resolveFirst(samplePage())); expect(toastError).not.toHaveBeenCalled(); }); @@ -175,7 +203,7 @@ describe("ResourcePane 资源面板", () => { const preloader = renderHook(() => usePreloadResourcePane("u1")); await waitFor(() => expect(getScriptResources).toHaveBeenCalledTimes(1)); preloader.unmount(); - getScriptResources.mockResolvedValue({}); + getScriptResources.mockResolvedValue({ items: [], offset: 0, limit: 100, total: 0 }); render(); diff --git a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.tsx b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.tsx index 7bed98cf1..6f1a4329f 100644 --- a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.tsx +++ b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Download, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { notify } from "@App/pages/components/ui/toast"; -import type { Resource } from "@App/app/repo/resource"; +import { RESOURCE_CHUNK_BYTES, RESOURCE_LIST_PAGE_SIZE, type ResourceListItem } from "@App/app/repo/resource"; import { fetchScript, resourceClient } from "@App/pages/store/features/script"; -import { base64ToBlob, formatBytes, makeBlobURL } from "@App/pkg/utils/utils"; +import { formatBytes, makeBlobURL } from "@App/pkg/utils/utils"; import { Badge } from "@App/pages/components/ui/badge"; import { Button } from "@App/pages/components/ui/button"; import { DataPanel, DataPanelEmpty, DataPanelHeader, DataPanelRow } from "@App/pages/components/ui/data-panel"; @@ -13,7 +13,7 @@ import { SearchInput } from "@App/pages/components/ui/search-input"; import { TooltipIconButton } from "@App/pages/components/ui/tooltip-icon-button"; import { createPreloadableQuery } from "@App/pages/preloadable-query"; -type ResItem = Resource & { key: string }; +type ResItem = ResourceListItem; // 资源类型 -> 展示用的元数据标记 const TYPE_BADGE: Record = { @@ -31,13 +31,17 @@ const resourcePaneQuery = createPreloadableQuery({ if (signal.aborted || !script) return []; - const res = await resourceClient.getScriptResources(script); - - if (signal.aborted) { - throw new DOMException("ResourcePane preload aborted", "AbortError"); + const items: ResItem[] = []; + let offset = 0; + for (;;) { + if (signal.aborted) { + throw new DOMException("ResourcePane preload aborted", "AbortError"); + } + const page = await resourceClient.getScriptResources(script, offset, RESOURCE_LIST_PAGE_SIZE); + items.push(...page.items); + if (page.nextOffset === undefined) return items; + offset = page.nextOffset; } - - return Object.keys(res).map((k) => ({ ...res[k], key: k })); }, }); @@ -61,18 +65,6 @@ export function usePreloadResourcePane(uuid?: string) { }, [uuid, t]); } -// 估算资源字节大小:优先用文本内容,其次用 base64 解码后的长度 -function resourceByteSize(r: Resource): number { - if (r.content) return new Blob([r.content]).size; - if (r.base64) { - const idx = r.base64.indexOf(","); - const b64 = idx >= 0 ? r.base64.slice(idx + 1) : r.base64; - const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; - return Math.max(0, Math.floor((b64.length * 3) / 4) - pad); - } - return 0; -} - function fileName(url: string): string { return url.split("/").pop() || url; } @@ -92,14 +84,14 @@ export default function ResourcePane({ uuid }: ResourcePaneProps) { return kw ? list.filter((r) => r.key.toLowerCase().includes(kw)) : list; }, [list, keyword]); - const totalBytes = useMemo(() => list.reduce((s, r) => s + resourceByteSize(r), 0), [list]); + const totalBytes = useMemo(() => list.reduce((s, r) => s + r.byteSize, 0), [list]); const onDelete = useCallback( - (url: string) => { + (resource: ResItem) => { resourceClient - .deleteResource(url) + .deleteResource(resource.url) .then(() => { - resources.setData((prev) => (prev ?? EMPTY_RESOURCES).filter((r) => r.key !== url)); + resources.setData((prev) => (prev ?? EMPTY_RESOURCES).filter((r) => r.key !== resource.key)); notify.success(t("delete_success")); }) .catch((e) => notify.error(`${t("editor:delete_failed")}: ${e.message}`)); @@ -108,7 +100,7 @@ export default function ResourcePane({ uuid }: ResourcePaneProps) { ); const onClear = useCallback(() => { - const urls = list.map((r) => r.key); + const urls = list.map((r) => r.url); Promise.all(urls.map((u) => resourceClient.deleteResource(u))) .then(() => { resources.setData([]); @@ -117,10 +109,38 @@ export default function ResourcePane({ uuid }: ResourcePaneProps) { .catch((e) => notify.error(`${t("editor:delete_failed")}: ${e.message}`)); }, [list, resources, t]); - const onDownload = useCallback((r: ResItem) => { - const url = makeBlobURL({ blob: base64ToBlob(r.base64), persistence: false }) as string; - void chrome.downloads.download({ url, saveAs: true, filename: fileName(r.key) }); - }, []); + const onDownload = useCallback( + (r: ResItem) => { + void (async () => { + const chunks: ArrayBuffer[] = []; + let offset = 0; + while (offset < r.byteSize) { + const chunk = await resourceClient.getResourceChunk({ + uuid, + url: r.url, + offset, + length: Math.min(RESOURCE_CHUNK_BYTES, r.byteSize - offset), + }); + if (chunk.offset !== offset || chunk.total !== r.byteSize || chunk.length === 0) { + throw new Error("resource chunk response is inconsistent"); + } + const binary = atob(chunk.base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + chunks.push(bytes.buffer as ArrayBuffer); + offset += chunk.length; + } + const url = await makeBlobURL({ + blob: new Blob(chunks, { type: r.contentType }), + persistence: false, + }); + await chrome.downloads.download({ url: url as string, saveAs: true, filename: fileName(r.key) }); + })().catch((e) => notify.error(`${t("script:operation_failed")}: ${e.message}`)); + }, + [t, uuid] + ); return (
@@ -189,9 +209,7 @@ export default function ResourcePane({ uuid }: ResourcePaneProps) { {/* md:contents 让大小/操作在桌面端回到与表头对齐的独立列 */}
- - {formatBytes(resourceByteSize(r))} - + {formatBytes(r.byteSize)}
onDelete(r.key)} + onConfirm={() => onDelete(r)} > From 65cbabdae1c3647459bc2d0393a50e6d6943c151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 17 Sep 2026 13:48:28 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=94=A5=20=E7=A7=BB=E9=99=A4=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=B1=82=E7=BB=9F=E4=B8=80=E7=9A=84=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E5=8C=96=E5=AD=97=E8=8A=82=E6=95=B0=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/custom_event_message.ts | 2 - packages/message/extension_message.ts | 6 -- packages/message/message_queue.ts | 21 ++-- packages/message/message_size.test.ts | 77 ------------- packages/message/message_size.ts | 102 ------------------ packages/message/server.test.ts | 13 --- packages/message/server.ts | 38 ++----- packages/message/window_message.ts | 15 +-- .../service/offscreen/event_page_manager.ts | 4 - .../offscreen/external-access-connect.ts | 8 +- 10 files changed, 19 insertions(+), 267 deletions(-) delete mode 100644 packages/message/message_size.test.ts delete mode 100644 packages/message/message_size.ts diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index e9865bf50..63b33038a 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -13,7 +13,6 @@ import { } from "@Packages/message/common"; import { ReadyWrap } from "@App/pkg/utils/ready-wrap"; import type { ScriptEnvTag } from "@Packages/message/consts"; -import { assertStructuredMessageSize } from "./message_size"; // 避免页面载入后改动 Map.prototype 导致消息传递失败 const relatedTargetMapMethods = { @@ -134,7 +133,6 @@ export class CustomEventMessage implements Message { nativeSend(detail: any) { if (!this.readyWrap.isReady) throw new Error("custom_event_message is not ready."); - assertStructuredMessageSize(detail, "CustomEvent.detail"); pageDispatchCustomEvent(this.sendFlag, detail); } diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 3a8f3c05c..e0702c98f 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -1,7 +1,6 @@ import EventEmitter from "eventemitter3"; import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessage, TMessageCommAction } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; -import { assertStructuredMessageSize } from "./message_size"; const listenerMgr = new EventEmitter(); // 单一管理器 @@ -10,7 +9,6 @@ export class ExtensionMessage implements Message { connect(data: TMessage): Promise { return new Promise((resolve) => { - assertStructuredMessageSize(data, "chrome.runtime.connect"); const con = chrome.runtime.connect(); con.postMessage(data); resolve(new ExtensionMessageConnect(con)); @@ -20,7 +18,6 @@ export class ExtensionMessage implements Message { // 发送消息 注意不进行回调的内存泄漏 sendMessage(data: TMessage): Promise { return new Promise((resolve: ((value: T) => void) | null) => { - assertStructuredMessageSize(data, "chrome.runtime.sendMessage"); chrome.runtime.sendMessage(data, (resp: T) => { const lastError = chrome.runtime.lastError; if (lastError) { @@ -191,7 +188,6 @@ export class ExtensionMessageConnect implements MessageConnect { // 無法 sendMessage 不应该屏蔽错误 throw new Error("Attempted to sendMessage on a disconnected port."); } - assertStructuredMessageSize(data, "chrome.runtime.Port.postMessage"); this.con.postMessage(data); } @@ -246,7 +242,6 @@ export class ExtensionContentMessageSend implements MessageSend { sendMessage(data: TMessage): Promise { return new Promise((resolve) => { - assertStructuredMessageSize(data, "chrome.tabs.sendMessage"); if (!this.options?.documentId && !this.options?.frameId) { // 发送给指定的tab chrome.tabs.sendMessage(this.tabId, data, (resp: T) => { @@ -272,7 +267,6 @@ export class ExtensionContentMessageSend implements MessageSend { connect(data: TMessage): Promise { return new Promise((resolve) => { - assertStructuredMessageSize(data, "chrome.tabs.connect"); const con = chrome.tabs.connect(this.tabId, this.options); con.postMessage(data); resolve(new ExtensionMessageConnect(con)); diff --git a/packages/message/message_queue.ts b/packages/message/message_queue.ts index e6a19fb8b..b681b0c73 100644 --- a/packages/message/message_queue.ts +++ b/packages/message/message_queue.ts @@ -2,7 +2,6 @@ import EventEmitter from "eventemitter3"; import LoggerCore from "@App/app/logger/core"; import { type TMessage } from "./types"; import type { SystemConfigKey, SystemConfigValueType } from "@App/pkg/config/config"; -import { assertStructuredMessageSize, MessageSizeError } from "./message_size"; export type TKeyValue = { key: T; @@ -80,13 +79,12 @@ export class MessageQueue implements IMessageQueue { // "Could not establish connection. Receiving end does not exist."——不接住就会变成 // 未处理的 Promise rejection。publish 本身是"广播给任何在监听的人",无人监听应静默忽略。 const messageQueueLogger = LoggerCore.getInstance().logger({ service: "messageQueue" }); - const outbound = { - msgQueue: topic, - data: { action: "message", message }, - }; - try { - assertStructuredMessageSize(outbound, "message-queue.publish"); - chrome.runtime.sendMessage(outbound).catch((e) => { + chrome.runtime + .sendMessage({ + msgQueue: topic, + data: { action: "message", message }, + }) + .catch((e) => { const msg = JSON.stringify(e?.message || e); if (msg.includes("Could not establish connection. Receiving end does not exist.")) { messageQueueLogger.debug("No target audience for .publish", { msg }); @@ -94,13 +92,6 @@ export class MessageQueue implements IMessageQueue { messageQueueLogger.error("Unable to execute runtime.sendMessage for .publish", { msg }); } }); - } catch (e) { - if (e instanceof MessageSizeError) { - messageQueueLogger.error("Unable to execute runtime.sendMessage for .publish", { error: e.message }); - } else { - throw e; - } - } this.EE.emit(topic, message); //@ts-ignore messageQueueLogger.trace("publish", { topic, message }); diff --git a/packages/message/message_size.test.ts b/packages/message/message_size.test.ts deleted file mode 100644 index 99ed37c8c..000000000 --- a/packages/message/message_size.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - MAX_EXTENSION_MESSAGE_BYTES, - MessageSizeError, - assertMessageSize, - measureMessageBytes, - measureStructuredMessageBytes, - utf8ByteLength, -} from "./message_size"; -import { ExtensionMessage } from "./extension_message"; - -describe("message size accounting", () => { - it("counts UTF-8 bytes after JSON escaping", () => { - const value = { data: '你好"\\😀' }; - const serialized = JSON.stringify(value); - - expect(utf8ByteLength(serialized)).toBe(new TextEncoder().encode(serialized).byteLength); - expect(measureMessageBytes(value)).toBe(utf8ByteLength(serialized)); - expect(measureMessageBytes(value)).toBeGreaterThan(value.data.length); - }); - - it("measures the complete envelope and accepts exact limits", () => { - const envelope = { messageId: "m1", type: "sendMessage", data: { action: "resource", data: "x" } }; - const bytes = measureMessageBytes(envelope); - - expect(() => assertMessageSize(envelope, "test-channel", bytes)).not.toThrow(); - expect(() => assertMessageSize(envelope, "test-channel", bytes - 1)).toThrow( - new MessageSizeError("test-channel", bytes, bytes - 1).message - ); - }); - - it("includes base64 expansion and reports deterministic over-limit diagnostics", () => { - const envelope = { - msgQueue: "backup", - data: { action: "message", message: { base64: "data:image/png;base64," + "A0==" } }, - }; - const bytes = measureMessageBytes(envelope); - const error = (() => { - try { - assertMessageSize(envelope, "message-queue", bytes - 1); - return undefined; - } catch (e) { - return e; - } - })(); - - expect(error).toBeInstanceOf(MessageSizeError); - expect((error as MessageSizeError).message).toBe( - `message-queue message exceeds limit: ${bytes} bytes (limit ${bytes - 1} bytes)` - ); - }); - - it("counts binary payloads on structured-clone channels", () => { - const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "application/octet-stream" }); - const envelope = { messageId: "m1", type: "sendMessage", data: { blob } }; - - expect(measureStructuredMessageBytes(envelope)).toBe(measureMessageBytes(envelope) + blob.size); - }); - - it("counts binary values nested in Map and Set structured-clone payloads", () => { - const mapBlob = new Blob([new Uint8Array([1, 2])]); - const setBlob = new Blob([new Uint8Array([3, 4, 5])]); - const envelope = { data: new Map([["blob", mapBlob]]), values: new Set([setBlob]) }; - - expect(measureStructuredMessageBytes(envelope)).toBe(measureMessageBytes(envelope) + mapBlob.size + setBlob.size); - }); - - it("rejects an oversized extension message before calling chrome.runtime", async () => { - const sendMessage = vi.spyOn(chrome.runtime, "sendMessage"); - const message = new ExtensionMessage(); - - await expect( - message.sendMessage({ action: "large", data: "x".repeat(MAX_EXTENSION_MESSAGE_BYTES) }) - ).rejects.toBeInstanceOf(MessageSizeError); - expect(sendMessage).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/message/message_size.ts b/packages/message/message_size.ts deleted file mode 100644 index c904f97f6..000000000 --- a/packages/message/message_size.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** Chrome's documented per-message runtime limit. */ -export const MAX_EXTENSION_MESSAGE_BYTES = 64 * 1024 * 1024; - -export class MessageSizeError extends Error { - constructor( - public readonly operation: string, - public readonly actualBytes: number, - public readonly limitBytes: number, - reason?: string - ) { - super( - reason - ? `${operation} message cannot be serialized: ${reason}` - : `${operation} message exceeds limit: ${actualBytes} bytes (limit ${limitBytes} bytes)` - ); - this.name = "MessageSizeError"; - } -} - -export function utf8ByteLength(value: string): number { - return new TextEncoder().encode(value).byteLength; -} - -export function serializeMessage(value: unknown): string { - const serialized = JSON.stringify(value); - if (serialized === undefined) { - throw new TypeError("JSON.stringify returned undefined"); - } - return serialized; -} - -export function measureMessageBytes(value: unknown): number { - return utf8ByteLength(serializeMessage(value)); -} - -export function measureStructuredMessageBytes(value: unknown): number { - return measureMessageBytes(value) + measureStructuredBinaryBytes(value); -} - -export function assertMessageSize(value: unknown, operation: string, limitBytes = MAX_EXTENSION_MESSAGE_BYTES): number { - let bytes: number; - try { - bytes = measureMessageBytes(value); - } catch (error) { - throw new MessageSizeError( - operation, - Number.NaN, - limitBytes, - error instanceof Error ? error.message : String(error) - ); - } - if (bytes > limitBytes) { - throw new MessageSizeError(operation, bytes, limitBytes); - } - return bytes; -} - -export function assertStructuredMessageSize( - value: unknown, - operation: string, - limitBytes = MAX_EXTENSION_MESSAGE_BYTES -): number { - let bytes: number; - try { - bytes = measureStructuredMessageBytes(value); - } catch (error) { - throw new MessageSizeError( - operation, - Number.NaN, - limitBytes, - error instanceof Error ? error.message : String(error) - ); - } - if (bytes > limitBytes) { - throw new MessageSizeError(operation, bytes, limitBytes); - } - return bytes; -} - -function measureStructuredBinaryBytes(value: unknown, seen = new WeakSet()): number { - if (typeof value !== "object" || value === null || seen.has(value)) return 0; - seen.add(value); - - if (typeof Blob !== "undefined" && value instanceof Blob) return value.size; - if (value instanceof ArrayBuffer) return value.byteLength; - if (ArrayBuffer.isView(value)) return Object.keys(value).length === 0 ? value.byteLength : 0; - - if (value instanceof Map) { - let total = 0; - for (const [key, child] of value) { - total += measureStructuredBinaryBytes(key, seen) + measureStructuredBinaryBytes(child, seen); - } - return total; - } - if (value instanceof Set) { - let total = 0; - for (const child of value) total += measureStructuredBinaryBytes(child, seen); - return total; - } - - return Object.values(value).reduce((total, child) => total + measureStructuredBinaryBytes(child, seen), 0); -} diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index ca9a5b2fe..b7b1ff007 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -3,7 +3,6 @@ import { GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } import { CustomEventMessage } from "./custom_event_message"; import type { MessageConnect, RuntimeMessageSender } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; -import { MAX_EXTENSION_MESSAGE_BYTES } from "./message_size"; let inboundMessage: CustomEventMessage; let outboundMessage: CustomEventMessage; @@ -460,18 +459,6 @@ describe("Server", () => { "level1-after", ]); }); - - it("过大的响应不会再次触发传输异常,而会返回有界诊断", async () => { - server.on("oversized-response", () => "x".repeat(MAX_EXTENSION_MESSAGE_BYTES)); - - const response = await client.sendMessage({ - action: "api/oversized-response", - data: {}, - }); - - expect(response.code).toBe(-1); - expect(response.message).toContain("message-server/oversized-response message exceeds limit"); - }); }); describe("IGetSender 功能测试", () => { diff --git a/packages/message/server.ts b/packages/message/server.ts index 6e8679f7e..3df109f80 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -3,7 +3,6 @@ import LoggerCore from "@App/app/logger/core"; import { connect, sendMessage } from "./client"; import { ExtensionMessageConnect } from "./extension_message"; import Logger from "@App/app/logger/logger"; -import { assertStructuredMessageSize, MessageSizeError } from "./message_size"; export const enum GetSenderType { CONNECT = 1, @@ -171,17 +170,6 @@ export class Server { this.apiFunctionMap.set(name, func); } - private sendConnectResponse(con: MessageConnect, response: TMessage, action: string) { - try { - assertStructuredMessageSize(response, `message-server/${action}`); - con.sendMessage(response); - } catch (e) { - if (!(e instanceof MessageSizeError)) throw e; - this.logger.error("message-server response exceeded transport limit", Logger.E(e)); - con.sendMessage({ code: -1, message: e.message }); - } - } - private connectHandle(msg: string, params: any, con: MessageConnect) { const func = this.apiFunctionMap.get(msg); if (func) { @@ -190,15 +178,15 @@ export class Server { if (ret instanceof Promise) { ret .then((data) => { - data && this.sendConnectResponse(con, { code: 0, data }, msg); + data && con.sendMessage({ code: 0, data }); }) .catch((e: Error) => { - this.sendConnectResponse(con, { code: -1, message: formatErrorToClient(e) }, msg); + con.sendMessage({ code: -1, message: formatErrorToClient(e) }); this.logger.error("connectHandle error", Logger.E(e)); }); return true; } else { - this.sendConnectResponse(con, { code: 0, data: ret }, msg); + con.sendMessage({ code: 0, data: ret }); } } return true; @@ -212,16 +200,6 @@ export class Server { sender: RuntimeMessageSender ) { const func = this.apiFunctionMap.get(action); - const respond = (response: any) => { - try { - assertStructuredMessageSize(response, `message-server/${action}`); - sendResponse(response); - } catch (e) { - if (!(e instanceof MessageSizeError)) throw e; - this.logger.error("message-server response exceeded transport limit", Logger.E(e)); - sendResponse({ code: -1, message: e.message }); - } - }; if (func) { try { const ret = func(params, new SenderRuntime(sender)); @@ -229,25 +207,25 @@ export class Server { ret .then((data) => { try { - respond({ code: 0, data }); + sendResponse({ code: 0, data }); } catch (e: any) { this.logger.error("sendResponse error", Logger.E(e)); } }) .catch((e: Error) => { - respond({ code: -1, message: formatErrorToClient(e) }); + sendResponse({ code: -1, message: formatErrorToClient(e) }); this.logger.error("messageHandle error", Logger.E(e)); }); return true; } else { - respond({ code: 0, data: ret }); + sendResponse({ code: 0, data: ret }); } } catch (e: any) { - respond({ code: -1, message: formatErrorToClient(e) }); + sendResponse({ code: -1, message: formatErrorToClient(e) }); this.logger.error("messageHandle error", Logger.E(e)); } } else { - respond({ code: -1, message: "no such api " + action }); + sendResponse({ code: -1, message: "no such api " + action }); this.logger.error("no such api", { action: action }); } } diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index b13d28577..9946bd089 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -8,7 +8,6 @@ import type { } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; import EventEmitter from "eventemitter3"; -import { assertStructuredMessageSize } from "./message_size"; const listenerMgr = new EventEmitter(); // 单一管理器 @@ -22,7 +21,6 @@ class WindowPostMessage implements PostMessage { constructor(private target: Window) {} postMessage(message: T): void { - assertStructuredMessageSize(message, "window.postMessage"); this.target.postMessage(message, "*"); } } @@ -119,7 +117,6 @@ export class WindowMessage implements Message { type: "connect", data, }; - assertStructuredMessageSize(body, "window.postMessage"); const target = this.getTarget(); target.postMessage(body, "*"); // 使用 WindowPostMessage 包装,确保后续 sendMessage 也带 "*" targetOrigin @@ -143,7 +140,6 @@ export class WindowMessage implements Message { type: "sendMessage", data, }; - assertStructuredMessageSize(body, "window.postMessage"); const eventId = `response:${messageId}`; this.EE.addListener(eventId, (body: WindowMessageBody) => { this.EE.removeAllListeners(eventId); @@ -196,7 +192,6 @@ export class WindowMessageConnect implements MessageConnect { type: "connectMessage", data, }; - assertStructuredMessageSize(body, "window.postMessage"); this.target.postMessage(body); } @@ -279,7 +274,6 @@ export class ServiceWorkerMessageSend implements Message { type: "respMessage", data: resp, }; - assertStructuredMessageSize(body, "serviceWorker.postMessage"); source.postMessage(body); }, {} as RuntimeMessageSender @@ -312,7 +306,6 @@ export class ServiceWorkerMessageSend implements Message { type: "connect", data, }; - assertStructuredMessageSize(body, "serviceWorker.postMessage"); this.target!.postMessage(body); return new WindowMessageConnect(body.messageId, this.EE, this.target!); } @@ -327,7 +320,6 @@ export class ServiceWorkerMessageSend implements Message { type: "sendMessage", data, }; - assertStructuredMessageSize(body, "serviceWorker.postMessage"); const eventId = `response:${messageId}`; this.EE.addListener(eventId, (body: WindowMessageBody) => { this.EE.removeAllListeners(eventId); @@ -371,11 +363,7 @@ export class ServiceWorkerClientMessage implements Message { this.EE.emit( "message", data.data, - (resp: any) => { - const body: WindowMessageBody = { messageId: data.messageId, type: "respMessage", data: resp }; - assertStructuredMessageSize(body, "serviceWorker.postMessage"); - source.postMessage(body); - }, + (resp: any) => source.postMessage({ messageId: data.messageId, type: "respMessage", data: resp }), {} as RuntimeMessageSender ); } else if (data.type === "connect" && source) { @@ -398,7 +386,6 @@ export class ServiceWorkerClientMessage implements Message { } private postToServiceWorker(message: any) { - assertStructuredMessageSize(message, "serviceWorker.postMessage"); if (this.sw) { this.sw.postMessage(message); } else { diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index 66982c76f..ac019c1ea 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -12,7 +12,6 @@ import EventEmitter from "eventemitter3"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { ServiceWorkerClient } from "../service_worker/client"; import { BackgroundEnvManagerBase } from "./base"; -import { assertStructuredMessageSize } from "@Packages/message/message_size"; class InProcessMessageConnect implements MessageConnect { private messages = new EventEmitter(); @@ -29,7 +28,6 @@ class InProcessMessageConnect implements MessageConnect { sendMessage(data: TMessage): void { if (!this.disconnected) { - assertStructuredMessageSize(data, "event-page.port.postMessage"); this.peer?.messages.emit("message", data); } } @@ -59,7 +57,6 @@ export class InProcessMessage implements Message, MessageSend { private events = new EventEmitter(); connect(data: TMessage): Promise { - assertStructuredMessageSize(data, "event-page.connect"); const client = new InProcessMessageConnect(); const server = new InProcessMessageConnect(); client.peer = server; @@ -71,7 +68,6 @@ export class InProcessMessage implements Message, MessageSend { } sendMessage(data: TMessage): Promise { - assertStructuredMessageSize(data, "event-page.sendMessage"); return new Promise((resolve) => { this.events.emit("message", data, resolve, {} as RuntimeMessageSender); }); diff --git a/src/app/service/offscreen/external-access-connect.ts b/src/app/service/offscreen/external-access-connect.ts index 16f954145..bff1b9305 100644 --- a/src/app/service/offscreen/external-access-connect.ts +++ b/src/app/service/offscreen/external-access-connect.ts @@ -5,7 +5,6 @@ import type { MessageSend } from "@Packages/message/types"; import { ExternalAccessConnectRelayClient } from "../service_worker/client"; import { CRYPTO, LIMITS } from "../service_worker/external_access/generated/protocol.generated"; import { decodeWireEnvelope } from "../service_worker/external_access/protocol-wire"; -import { utf8ByteLength } from "@Packages/message/message_size"; import type { AuthChallengePayload, AuthMode, @@ -251,9 +250,10 @@ export class ExternalAccessConnect { private async handleMessage(ev: MessageEvent, sessionEpoch: number): Promise { if (sessionEpoch !== this.epoch) return; - if (typeof ev.data !== "string" || utf8ByteLength(ev.data) > LIMITS.maxFrameBytes) { + const inboundBytes = typeof ev.data === "string" ? new TextEncoder().encode(ev.data).byteLength : undefined; + if (inboundBytes === undefined || inboundBytes > LIMITS.maxFrameBytes) { this.logger.warn("Dropped inbound WebSocket frame over limit", { - bytes: typeof ev.data === "string" ? utf8ByteLength(ev.data) : undefined, + bytes: inboundBytes, limit: LIMITS.maxFrameBytes, }); this.ws?.close(); @@ -367,7 +367,7 @@ export class ExternalAccessConnect { private rawSend(envelope: WSEnvelope): void { if (this.ws?.readyState === WebSocket.OPEN) { const frame = JSON.stringify(envelope); - const bytes = utf8ByteLength(frame); + const bytes = new TextEncoder().encode(frame).byteLength; if (bytes > LIMITS.maxFrameBytes) { this.logger.warn("Dropped outbound WebSocket frame over limit", { method: envelope.method, From 6d179850b54b64b5d940c2f770fa3c4ed7624288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 17 Sep 2026 14:25:20 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=94=A5=20=E7=A7=BB=E9=99=A4=20Externa?= =?UTF-8?q?l=20Access=20=E5=B8=A7=E5=A4=A7=E5=B0=8F=E4=B8=8E=20grep=20?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=AD=97=E8=8A=82=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../offscreen/external-access-connect.test.ts | 25 --------- .../offscreen/external-access-connect.ts | 22 +------- .../external_access/source.test.ts | 13 +---- .../service_worker/external_access/source.ts | 51 ++++--------------- 4 files changed, 12 insertions(+), 99 deletions(-) diff --git a/src/app/service/offscreen/external-access-connect.test.ts b/src/app/service/offscreen/external-access-connect.test.ts index 4a54fd561..990dc0a72 100644 --- a/src/app/service/offscreen/external-access-connect.test.ts +++ b/src/app/service/offscreen/external-access-connect.test.ts @@ -405,31 +405,6 @@ describe("ExternalAccessConnect", () => { (externalAccessConnect as any).sendEnvelope({ jsonrpc: "2.0", id: "r1", result: {} }); expect(ws.sentMessages.length).toBe(0); }); - - it("出站 WebSocket frame 超过协议上限时被丢弃", async () => { - const ws = await completeSessionHandshake(); - const sentCount = ws.sentMessages.length; - (externalAccessConnect as any).sendEnvelope({ - jsonrpc: "2.0", - id: "too-large", - result: { source: "x".repeat(LIMITS.maxFrameBytes) }, - }); - - expect(ws.sentMessages.length).toBe(sentCount); - }); - - it("入站 WebSocket frame 超过协议上限时关闭连接", async () => { - const ws = await completeSessionHandshake(); - const closeSpy = vi.spyOn(ws, "close"); - ws.simulateMessage({ - jsonrpc: "2.0", - method: "scripts.list", - params: { input: "x".repeat(LIMITS.maxFrameBytes) }, - }); - - await Promise.resolve(); - expect(closeSpy).toHaveBeenCalled(); - }); }); // ──────────────────────────────────────────────── diff --git a/src/app/service/offscreen/external-access-connect.ts b/src/app/service/offscreen/external-access-connect.ts index bff1b9305..8203abc17 100644 --- a/src/app/service/offscreen/external-access-connect.ts +++ b/src/app/service/offscreen/external-access-connect.ts @@ -250,16 +250,6 @@ export class ExternalAccessConnect { private async handleMessage(ev: MessageEvent, sessionEpoch: number): Promise { if (sessionEpoch !== this.epoch) return; - const inboundBytes = typeof ev.data === "string" ? new TextEncoder().encode(ev.data).byteLength : undefined; - if (inboundBytes === undefined || inboundBytes > LIMITS.maxFrameBytes) { - this.logger.warn("Dropped inbound WebSocket frame over limit", { - bytes: inboundBytes, - limit: LIMITS.maxFrameBytes, - }); - this.ws?.close(); - return; - } - let envelope: WSEnvelope; try { envelope = decodeWireEnvelope(ev.data as string); @@ -366,17 +356,7 @@ export class ExternalAccessConnect { private rawSend(envelope: WSEnvelope): void { if (this.ws?.readyState === WebSocket.OPEN) { - const frame = JSON.stringify(envelope); - const bytes = new TextEncoder().encode(frame).byteLength; - if (bytes > LIMITS.maxFrameBytes) { - this.logger.warn("Dropped outbound WebSocket frame over limit", { - method: envelope.method, - bytes, - limit: LIMITS.maxFrameBytes, - }); - return; - } - this.ws.send(frame); + this.ws.send(JSON.stringify(envelope)); } } diff --git a/src/app/service/service_worker/external_access/source.test.ts b/src/app/service/service_worker/external_access/source.test.ts index bedd460e1..8ce8ee351 100644 --- a/src/app/service/service_worker/external_access/source.test.ts +++ b/src/app/service/service_worker/external_access/source.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { sliceLines, grepLines, applyTextEdits, MAX_GREP_RESULT_BYTES } from "./source"; +import { sliceLines, grepLines, applyTextEdits } from "./source"; import { ExternalAccessBridgeError } from "./errors"; describe("sliceLines(scripts.source.get 的行开窗,1-based 闭区间)", () => { @@ -136,17 +136,6 @@ describe("grepLines(scripts.source.grep 的逐行匹配,不复用 stringMatc expect(result.truncated).toBe(true); }); - it("匹配结果总字节数受上限约束,超长命中不会在 service worker 中累积", () => { - const result = grepLines(`hit ${"x".repeat(MAX_GREP_RESULT_BYTES)}`, "hit"); - - expect(result.totalMatches).toBe(1); - expect(result.matches).toEqual([]); - expect(result.truncated).toBe(true); - expect(new TextEncoder().encode(JSON.stringify(result.matches)).byteLength).toBeLessThanOrEqual( - MAX_GREP_RESULT_BYTES - ); - }); - it("未触发 maxMatches 截断时 truncated 为 false", () => { const result = grepLines(code, "import"); expect(result.truncated).toBe(false); diff --git a/src/app/service/service_worker/external_access/source.ts b/src/app/service/service_worker/external_access/source.ts index 7e12806b1..67624b5ba 100644 --- a/src/app/service/service_worker/external_access/source.ts +++ b/src/app/service/service_worker/external_access/source.ts @@ -8,10 +8,6 @@ import type { ScriptSource, ScriptSourceGrepMatch, ScriptSourceGrepResult } from // slice (design §4.1), not the underlying file, so a line window can be pulled out of an // oversized script while a full read keeps behaving exactly as before. export const MAX_SOURCE_BYTES = 2 * 1024 * 1024; -// Keep grep responses well below the WebSocket frame cap after JSON-RPC envelopes and UTF-8 -// encoding are added. A bounded result also prevents a set of long matching lines from building -// an unbounded response in the service worker. -export const MAX_GREP_RESULT_BYTES = 512 * 1024; export interface SlicedLines { code: string; @@ -132,10 +128,6 @@ export interface GrepLinesResult { totalLines: number; } -function serializedBytes(value: unknown): number { - return new TextEncoder().encode(JSON.stringify(value)).byteLength; -} - // Compiling the user's pattern is itself a validation step, so acceptance-time checking and the // actual scan share it rather than each spelling out their own try/catch. function compileGrepPattern(query: string, mode: "text" | "regex", ignoreCase: boolean): RegExp | undefined { @@ -202,7 +194,6 @@ export function grepLines( const totalLines = lines.length; const matches: ScriptSourceGrepMatch[] = []; let totalMatches = 0; - let resultTruncated = false; const skippedLongLines = 0; const startedAt = clock(); @@ -220,27 +211,16 @@ export function grepLines( totalMatches++; if (matches.length < maxMatches) { - const match = { + matches.push({ lineNumber: i + 1, line, before: lines.slice(Math.max(0, i - contextLines), i), after: lines.slice(i + 1, i + 1 + contextLines), - }; - if (serializedBytes([...matches, match]) <= MAX_GREP_RESULT_BYTES) { - matches.push(match); - } else { - resultTruncated = true; - } + }); } } - return { - matches, - totalMatches, - truncated: resultTruncated || totalMatches > matches.length, - skippedLongLines, - totalLines, - }; + return { matches, totalMatches, truncated: totalMatches > matches.length, skippedLongLines, totalLines }; } /** @@ -260,30 +240,19 @@ export async function grepScriptSource( const scriptCode = await scriptCodeDAO.get(uuid); if (!scriptCode) throw new ExternalAccessBridgeError("NOT_FOUND", "script source not found"); - const grepResult = grepLines(scriptCode.code, query, options); - const result: ScriptSourceGrepResult = { + const result = grepLines(scriptCode.code, query, options); + return { uuid: script.uuid, name: script.name, version: script.metadata.version?.[0], - matches: grepResult.matches, - totalMatches: grepResult.totalMatches, - truncated: grepResult.truncated, - skippedLongLines: grepResult.skippedLongLines, - totalLines: grepResult.totalLines, + matches: result.matches, + totalMatches: result.totalMatches, + truncated: result.truncated, + skippedLongLines: result.skippedLongLines, + totalLines: result.totalLines, sha256: sha256OfText(scriptCode.code), contentTrust: "untrusted-user-script-source", }; - if (serializedBytes(result) <= MAX_GREP_RESULT_BYTES) return result; - - const matches = [...result.matches]; - while (matches.length > 0 && serializedBytes({ ...result, matches }) > MAX_GREP_RESULT_BYTES) { - matches.pop(); - } - const bounded = { ...result, matches, truncated: true }; - if (serializedBytes(bounded) > MAX_GREP_RESULT_BYTES) { - throw new ExternalAccessBridgeError("PAYLOAD_TOO_LARGE", "grep result exceeds 512 KiB"); - } - return bounded; } export interface TextEdit { From 2a52473aa3bd5a8c8dfa3ec05deb4ca647a083de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 17 Sep 2026 14:42:05 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=88=86=E5=9D=97=E6=89=A9=E5=A4=A7=E5=88=B0?= =?UTF-8?q?=2016=20MiB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/repo/resource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/repo/resource.ts b/src/app/repo/resource.ts index 5a2612ec5..311f17777 100644 --- a/src/app/repo/resource.ts +++ b/src/app/repo/resource.ts @@ -49,7 +49,7 @@ export interface ResourceChunk { } export const RESOURCE_LIST_PAGE_SIZE = 100; -export const RESOURCE_CHUNK_BYTES = 512 * 1024; +export const RESOURCE_CHUNK_BYTES = 16 * 1024 * 1024; export function getResourceByteSize(resource: { content: string; base64?: string }): number { if (resource.base64) { From 34b895905bdbb63b41a9fc1825534302ba412e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 18 Sep 2026 11:05:02 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=85=20=E8=A6=86=E7=9B=96=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=88=86=E9=A1=B5=E4=B8=8E=E5=88=86=E5=9D=97=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E7=9A=84=E7=BB=AD=E5=8F=96=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分页的 nextOffset 续取、分块下载的 offset 推进与 total 一致性校验、 以及生产环境真正会走的 data-URI base64 分支此前都没有测试;参数上限 (page size / chunk length)的拒绝分支同样未覆盖。 --- .../service/service_worker/resource.test.ts | 69 ++++++++++++++++++- .../ScriptEditor/tabs/ResourcePane.test.tsx | 58 ++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/resource.test.ts b/src/app/service/service_worker/resource.test.ts index 35eafa797..9651016a7 100644 --- a/src/app/service/service_worker/resource.test.ts +++ b/src/app/service/service_worker/resource.test.ts @@ -6,7 +6,7 @@ import type { IMessageQueue } from "@Packages/message/message_queue"; import { parseUrlSRI } from "./utils"; import type { Script } from "@App/app/repo/scripts"; import { SCRIPT_RUN_STATUS_COMPLETE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; -import type { Resource } from "@App/app/repo/resource"; +import { RESOURCE_CHUNK_BYTES, RESOURCE_LIST_PAGE_SIZE, type Resource } from "@App/app/repo/resource"; initTestEnv(); @@ -412,6 +412,15 @@ describe("ResourceService - getResourceByTypes", () => { }); }); +// 生产环境的 base64 是 blobToBase64 产出的 data URI,resourceModel 的 btoa(content) 不是这种形式 +function dataUriResource(): Resource { + return { + ...resourceModel("https://example.com/logo.png", ""), + contentType: "image/png", + base64: "data:image/png;base64,iVBORw0KGgr/AQ==", + }; +} + describe("ResourceService - resource list and chunks", () => { let service: ResourceService; @@ -462,6 +471,64 @@ describe("ResourceService - resource list and chunks", () => { expect([...Uint8Array.from(atob(chunk.base64), (char) => char.charCodeAt(0))]).toEqual([0xbd, 0xa0, 0xe5, 0xa5]); }); + it("measures byteSize by decoded bytes, not by the encoded data-URI string", async () => { + vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({ logo: dataUriResource() }); + + const page = await service.getScriptResourcePage(normalScript("script-binary", {}), 0, 1); + + expect(page.items[0]).toMatchObject({ key: "logo", contentType: "image/png", byteSize: 10 }); + }); + + it("slices a data-URI base64 resource by decoded byte offsets", async () => { + const resource = dataUriResource(); + vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource); + + const chunk = await service.getResourceChunk({ uuid: "old-script", url: resource.url, offset: 2, length: 4 }); + + expect(chunk).toMatchObject({ offset: 2, length: 4, total: 10, base64: "TkcNCg==" }); + }); + + it("keeps advancing nextOffset until the last page", async () => { + const first = { ...resourceModel("https://example.com/a.txt", "a"), base64: "" }; + const second = { ...resourceModel("https://example.com/b.txt", "b"), base64: "" }; + vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({ a: first, b: second }); + const script = normalScript("script-pages", {}); + + const page0 = await service.getScriptResourcePage(script, 0, 1); + const page1 = await service.getScriptResourcePage(script, page0.nextOffset!, 1); + + expect(page0).toMatchObject({ offset: 0, total: 2, nextOffset: 1 }); + expect(page0.items.map((item) => item.url)).toEqual([first.url]); + expect(page1).toMatchObject({ offset: 1, total: 2, nextOffset: undefined }); + expect(page1.items.map((item) => item.url)).toEqual([second.url]); + }); + + it("accepts the exact page-size limit and rejects a larger one", async () => { + vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({}); + const script = normalScript("script-limit", {}); + + await expect(service.getScriptResourcePage(script, 0, RESOURCE_LIST_PAGE_SIZE)).resolves.toMatchObject({ + limit: RESOURCE_LIST_PAGE_SIZE, + }); + await expect(service.getScriptResourcePage(script, 0, RESOURCE_LIST_PAGE_SIZE + 1)).rejects.toThrow( + /resource list limit must be between/ + ); + }); + + it("accepts the exact chunk-size limit and rejects a larger one", async () => { + const resource = { ...resourceModel("https://example.com/data.txt", "data"), base64: "" }; + vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource); + const request = { uuid: "old-script", url: resource.url, offset: 0 }; + + await expect(service.getResourceChunk({ ...request, length: RESOURCE_CHUNK_BYTES })).resolves.toMatchObject({ + length: 4, + total: 4, + }); + await expect(service.getResourceChunk({ ...request, length: RESOURCE_CHUNK_BYTES + 1 })).rejects.toThrow( + /resource chunk length must be between/ + ); + }); + it("does not expose chunks to a script that does not own the resource", async () => { const resource = resourceModel("https://example.com/private.txt", "private"); vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource); diff --git a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx index 44e852a82..75e913a49 100644 --- a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx +++ b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx @@ -87,6 +87,64 @@ describe("ResourcePane 资源面板", () => { expect(downloadSpy).toHaveBeenCalledWith(expect.objectContaining({ filename: "jquery.min.js", saveAs: true })); }); + it("资源大于单块时应按 offset 续取并拼回完整内容", async () => { + // 服务端按块回传,客户端必须用 chunk.length 推进 offset,否则下载到的文件会截断或重复 + getResourceChunk.mockImplementation(({ offset }: { offset: number }) => + Promise.resolve({ + url: "https://cdn.test/jquery.min.js", + offset, + length: 4, + total: 8, + base64: offset === 0 ? "dmFyIA==" : "YT0xOw==", + }) + ); + vi.spyOn(chrome.downloads, "download").mockResolvedValue(undefined); + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:resource-pane"); + render(); + await screen.findByText("jquery.min.js"); + + fireEvent.click(screen.getAllByLabelText(t("download"))[0]); + + await waitFor(() => expect(createObjectURL).toHaveBeenCalled()); + expect(getResourceChunk.mock.calls.map(([params]) => params.offset)).toEqual([0, 4]); + const blobs = createObjectURL.mock.calls + .map(([value]) => value) + .filter((value): value is Blob => value instanceof Blob); + expect(blobs).toHaveLength(1); + await expect(blobs[0].text()).resolves.toBe("var a=1;"); + }); + + it("资源块与列表元数据不一致时应报错且不触发下载", async () => { + getResourceChunk.mockResolvedValue({ + url: "https://cdn.test/jquery.min.js", + offset: 0, + length: 4, + total: 4, // 与列表里的 byteSize: 8 不一致,说明资源在两次请求之间被改写 + base64: "dmFyIA==", + }); + const downloadSpy = vi.spyOn(chrome.downloads, "download").mockResolvedValue(undefined); + const toastError = vi.spyOn(notify, "error"); + render(); + await screen.findByText("jquery.min.js"); + + fireEvent.click(screen.getAllByLabelText(t("download"))[0]); + + await waitFor(() => expect(toastError).toHaveBeenCalled()); + expect(downloadSpy).not.toHaveBeenCalled(); + }); + + it("列表分页应按 nextOffset 续取并合并展示", async () => { + getScriptResources + .mockResolvedValueOnce({ items: [sampleResources()[0]], offset: 0, limit: 1, total: 2, nextOffset: 1 }) + .mockResolvedValueOnce({ items: [sampleResources()[1]], offset: 1, limit: 1, total: 2 }); + + render(); + + expect(await screen.findByText("jquery.min.js")).toBeInTheDocument(); + expect(await screen.findByText("theme.css")).toBeInTheDocument(); + expect(getScriptResources.mock.calls.map(([, offset]) => offset)).toEqual([0, 1]); + }); + it("行内删除应二次确认后才调用 deleteResource 并移除该行", async () => { render(); await screen.findByText("jquery.min.js");