// 沙箱任务表(docs/adr/ADR-005-sandbox.md):只放「外部不可信字节 → 结构化数据」的纯函数。 // 子进程加载完本表后撤销全部权限(文件 / 网络 / 环境变量 / 子进程),所以任务里不能有任何 I/O; // 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。 import { parseBangumiBrowser, parseTorrent } from "@umt/adapters"; import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain"; import { buildPreview } from "../modules/file-manager/preview-build.ts"; import { PAGE_PARSERS, type PageKind } from "../modules/subtitles/online/parse.ts"; import { archiveKindOf, extractArchive, prepareArchives } from "./archives.ts"; import { solveSubhdCaptcha, solveZimukuCaptcha } from "./captcha.ts"; const MAX_SUBTITLE_BYTES = 10 * 1024 * 1024; const MAX_ARCHIVE_ENTRIES = 300; export const b64encode = (bytes: Uint8Array) => { let s = ""; for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); return btoa(s); }; export const b64decode = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); export interface ParsedSubtitleFile { name: string; archive: string | null; ext: string; language: string; season: number | null; seasonInferred: boolean; episode: number | null; encoding: string; sizeBytes: number; /** 转成 UTF-8 后的内容(base64) */ content: string; } /** 子进程撤销权限前调用:预热要读文件的 WASM 库 */ export async function prepareJobs(): Promise { await prepareArchives(); } /** 字幕 / zip / rar / 7z:解压、解码转 UTF-8、识别语言与季集 */ async function subtitleParse(input: { files: { name: string; base64: string }[] }) { const items: ParsedSubtitleFile[] = []; const skipped: { name: string; reason: string }[] = []; const one = (name: string, bytes: Uint8Array, archive: string | null) => { const ext = subtitleExtOf(name); if (!ext) return skipped.push({ name, reason: "不是字幕文件" }); if (bytes.length > MAX_SUBTITLE_BYTES) return skipped.push({ name, reason: "文件超过 10 MB" }); const { text, encoding } = decodeSubtitle(bytes); const ep = subtitleEpisode(name); items.push({ name, archive, ext, language: detectSubtitleLanguage(name, text), season: ep.season, seasonInferred: ep.seasonInferred, episode: ep.episode, encoding, sizeBytes: bytes.length, content: b64encode(new TextEncoder().encode(text)), }); }; for (const f of input.files) { let bytes: Uint8Array; try { bytes = b64decode(f.base64); } catch { skipped.push({ name: f.name, reason: "内容不是有效的 base64" }); continue; } const kind = archiveKindOf(f.name); if (kind) { let entries: Record; try { let n = 0; entries = await extractArchive( kind, bytes, (name, size) => !!subtitleExtOf(name) && size <= MAX_SUBTITLE_BYTES && ++n <= MAX_ARCHIVE_ENTRIES, ); } catch { skipped.push({ name: f.name, reason: "压缩包损坏或加密" }); continue; } const names = Object.keys(entries); if (!names.length) skipped.push({ name: f.name, reason: "压缩包里没有字幕文件" }); for (const n of names) one(n, entries[n]!, f.name); } else one(f.name, bytes, null); } return { items, skipped }; } /** 种子文件:info hash、名称、文件清单(TorrentMeta,可 JSON 化) */ function torrentParse(input: { base64: string }) { return parseTorrent(b64decode(input.base64)); } /** 测试用:按指令卡死 / 吃内存 / 抛错,验证父进程的超时强杀与内存上限 */ function selfTest(input: { mode: "ok" | "hang" | "memory" | "throw" | "io" }) { if (input.mode === "hang") for (;;) {} if (input.mode === "memory") { const keep: Uint8Array[] = []; for (;;) keep.push(new Uint8Array(16 * 1024 * 1024).fill(1)); } if (input.mode === "throw") throw new Error("任务自己抛的错"); if (input.mode === "io") return { read: Deno.readTextFileSync("/etc/hostname") }; return { ok: true }; } /** SubHD SVG / 字幕库 BMP 验证码 */ function captchaSolve(input: { kind: "subhd"; svg: string } | { kind: "zimuku"; bmpBase64: string }) { return { code: input.kind === "subhd" ? solveSubhdCaptcha(input.svg) : solveZimukuCaptcha(b64decode(input.bmpBase64)), }; } /** 字幕站页面 → 结构化数据(网页同样不可信) */ function subtitlePage(input: { kind: PageKind; html: string }) { const fn = PAGE_PARSERS[input.kind] as (html: string) => unknown; if (!fn) throw new Error(`没有这种页面解析:${input.kind}`); return fn(input.html); } /** bgm.tv 动画浏览页(热门趋势,发现页)→ 条目列表 */ function bangumiTrends(input: { html: string }) { return parseBangumiBrowser(input.html); } export const SANDBOX_JOBS = { "subtitle.parse": subtitleParse, "torrent.parse": torrentParse, "file.preview": buildPreview, "captcha.solve": captchaSolve, "subtitle.page": subtitlePage, "bangumi.trends": bangumiTrends, "self.test": selfTest, } as const; export type SandboxJobName = keyof typeof SANDBOX_JOBS; export type SandboxInput = Parameters<(typeof SANDBOX_JOBS)[J]>[0]; export type SandboxOutput = Awaited>;