From eeec91e892b0e7457edfce74d50ebae165c2cfd2 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 01:44:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(sandbox):=20=E4=B8=8D=E5=8F=AF=E4=BF=A1?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E5=9C=A8=E6=B2=99=E7=AE=B1=E5=AD=90=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E9=87=8C=E8=A7=A3=E6=9E=90(=E6=92=A4=E9=94=80?= =?UTF-8?q?=E6=9D=83=E9=99=90=E3=80=81=E8=B6=85=E6=97=B6=E4=B8=8E=E5=86=85?= =?UTF-8?q?=E5=AD=98=E5=BC=BA=E6=9D=80);=E7=A7=8D=E5=AD=90=E3=80=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=A2=84=E8=A7=88=E3=80=81=E5=AD=97=E5=B9=95?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E8=BF=81=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + CLAUDE.md | 1 + apps/server/src/main.ts | 13 ++ apps/server/src/modules/browser/service.ts | 4 +- .../src/modules/file-manager/preview-build.ts | 113 ++++++++++++ .../src/modules/file-manager/preview.ts | 122 +++---------- apps/server/src/modules/subtitles/service.ts | 83 ++------- apps/server/src/sandbox/client.ts | 162 ++++++++++++++++++ apps/server/src/sandbox/install.ts | 16 ++ apps/server/src/sandbox/jobs.ts | 115 +++++++++++++ apps/server/src/sandbox/worker.ts | 32 ++++ apps/server/test/sandbox/sandbox.test.ts | 51 ++++++ apps/server/vitest.config.ts | 2 + docs/adr/ADR-005-sandbox.md | 38 ++++ packages/adapters/src/index.ts | 1 + packages/adapters/src/isolation.ts | 29 ++++ packages/adapters/src/qbittorrent/client.ts | 5 +- packages/adapters/src/sites/mteam/adapter.ts | 6 +- packages/adapters/src/sites/spider/adapter.ts | 5 +- packages/adapters/src/sites/spiders/common.ts | 5 +- .../src/sites/spiders/public-detail.ts | 4 +- 21 files changed, 632 insertions(+), 178 deletions(-) create mode 100644 apps/server/src/modules/file-manager/preview-build.ts create mode 100644 apps/server/src/sandbox/client.ts create mode 100644 apps/server/src/sandbox/install.ts create mode 100644 apps/server/src/sandbox/jobs.ts create mode 100644 apps/server/src/sandbox/worker.ts create mode 100644 apps/server/test/sandbox/sandbox.test.ts create mode 100644 docs/adr/ADR-005-sandbox.md create mode 100644 packages/adapters/src/isolation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea35236..df21bcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 调整(安全) +- 不可信字节改在沙箱子进程里解析(ADR-005):种子文件(站点下载校验、qB 提交前、浏览器下载核验、公开 BT 站读文件列表)、文件管理预览、补字幕的解码与解压。子进程撤销全部权限,卡死超时强杀、内存超限强杀,不拖垮主服务。 + ## [0.3.22] - 2026-10-03 ### 新增 diff --git a/CLAUDE.md b/CLAUDE.md index 272a0883..0844a141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ REST 端点定义按领域分在 `packages/contracts/src/api/*.ts`,`api.ts` - **先提交再部署**。发布只走 `deno task release`:工作区不干净直接拒绝;版本取根 `deno.json`(所有成员包同一版本);`CHANGELOG.md` 必须先有 `## [x.y.z]` 条目;脚本打 tag `vx.y.z`、构建镜像、只替换 app 容器(启动时自动迁移)、核对健康接口版本。测试环境不需要备份、在途任务清单、源码指纹等重流程。 - 功能在分支上做,完成后合回 main;提交按功能拆,别把不相关的改动带进同一个提交。 - 说"检查通过"只认 `deno task test:all` / `deno task ci` 的实际输出;跳过的用例不算通过。 +- 解析外部下载 / 上传的文件或字节(种子、字幕、压缩包、图片等)一律走沙箱任务 `runSandbox`(`apps/server/src/sandbox/`,见 ADR-005),不在主进程里直接解析。 - 新增 Agent 能力要登记到某个 Skill(`packages/agent/skills/*/SKILL.md`)和能力地图(`packages/agent/src/prompt/layers/capabilities.ts`),否则 agent 测试会红。 - 界面文案守 `docs/design/glossary.md` 的术语与标点(`deno task check:copy`);状态色只表示状态(`StatusBadge` 带圆点,别拿来显示数量)。 - 主工作区不留常驻 dev 进程(`--watch` 会改写 `deno.lock`);构建前确认 `deno.lock` 没被改。 diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index cf7065f6..a2b206cb 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -12,6 +12,8 @@ import type { MappingExport } from "./ops.ts"; // (逐行事务、幂等可重跑;步骤与回滚见 docs/runbook/auth.md) // fsworker fsproxy 执行子进程(编译二进制里由 fsproxy 以 // `<自身> --role fsworker` 拉起,stdin 一条请求) +// sandbox 沙箱子进程(src/sandbox:撤销全部权限后解析不可信 +// 字节,stdin 一条请求,stdout 一条结果) const Roles = [ "api", "worker", @@ -24,6 +26,7 @@ const Roles = [ "read-only-on", "rotate-master-key", "fsworker", + "sandbox", ] as const; type Role = (typeof Roles)[number]; const OneShot = ["migrate", "preflight", "export-mappings", "read-only-on", "rotate-master-key"] as const; @@ -53,6 +56,13 @@ if (role === "fsworker") { Deno.exit(0); } +// 沙箱子进程:同样在加载其余模块前分派,只加载沙箱任务表 +if (role === "sandbox") { + const { sandboxWorkerMain } = await import("./sandbox/worker.ts"); + await sandboxWorkerMain(); + Deno.exit(0); +} + // 健康探测:只打本机回环端口,不需要完整配置(HEALTHCHECK 用,镜像里不装 curl) if (role === "probe") { const port = Deno.env.get("PORT") ?? "4380"; @@ -78,6 +88,9 @@ const { diffCounts, enableReadOnlyReconcile, exportMappings, writeMappingExport const { auditStorageWarnings, checkStorage, loadMountedPaths, runPreflight } = await import("./preflight.ts"); const { startWorker } = await import("./worker/index.ts"); const { laneOverridesFromEnv } = await import("./worker/lanes.ts"); +const { installSandboxParsers } = await import("./sandbox/install.ts"); +// 不可信字节(种子等)的解析改走沙箱子进程;一次性子命令也装上,无副作用 +installSandboxParsers(); let config: Config; try { diff --git a/apps/server/src/modules/browser/service.ts b/apps/server/src/modules/browser/service.ts index e0769e85..faf92d39 100644 --- a/apps/server/src/modules/browser/service.ts +++ b/apps/server/src/modules/browser/service.ts @@ -16,7 +16,7 @@ import { InMemoryProfileLock, imageDimensions, type PageContent, - parseTorrent, + parseTorrentIsolated, prepareImage, profileKeyFor, type TabRef, @@ -2237,7 +2237,7 @@ export class BrowserService { }; try { const bytes = new Uint8Array(await readFile(this.stagedPath(artifact.bytes.ref))); - const meta = parseTorrent(bytes); + const meta = await parseTorrentIsolated(bytes); verification = { valid: true, infohashV1: meta.infohashV1, diff --git a/apps/server/src/modules/file-manager/preview-build.ts b/apps/server/src/modules/file-manager/preview-build.ts new file mode 100644 index 00000000..a3a615a3 --- /dev/null +++ b/apps/server/src/modules/file-manager/preview-build.ts @@ -0,0 +1,113 @@ +// 文件预览的纯计算(沙箱任务 file.preview):文件头字节 → 预览结构;读文件在主进程(preview.ts)。 +import { type BencodeValue, bdecode, parseTorrent } from "@umt/adapters"; +import type { FilePreview } from "@umt/contracts"; +import { + CUE_LIMIT, + decodeText, + IMAGE_MIME, + imageSize, + maskTracker, + parseAss, + parseSrtLike, + parseSup, +} from "./preview-parse.ts"; + +export const TORRENT_FILES = 500; +/** 不是有效 .torrent 时抛出的错误信息(主进程据此转成 400) */ +export const BAD_TORRENT = "BAD_TORRENT"; + +const str = (v: BencodeValue | undefined) => (v instanceof Uint8Array ? new TextDecoder().decode(v) : null); + +function trackersOf(root: { [k: string]: BencodeValue }): string[] { + const out: string[] = []; + const tiers = root["announce-list"]; + if (Array.isArray(tiers)) + for (const tier of tiers) for (const t of Array.isArray(tier) ? tier : [tier]) out.push(str(t) ?? ""); + out.push(str(root.announce) ?? ""); + return [...new Set(out.filter(Boolean).map(maskTracker))]; +} + +export interface PreviewInput { + kind: "image" | "torrent" | "subtitle" | "text"; + ext: string; + /** 文件头字节(base64) */ + base64: string; + /** 文件实际大小 */ + size: number; +} + +export function buildPreview(input: PreviewInput): FilePreview { + const { kind, ext, size } = input; + const buf = Uint8Array.from(atob(input.base64), (c) => c.charCodeAt(0)); + if (kind === "image") { + const dim = imageSize(buf); + return { + type: "image", + size, + mime: IMAGE_MIME[ext] ?? "application/octet-stream", + width: dim?.width ?? null, + height: dim?.height ?? null, + }; + } + if (kind === "torrent") { + let meta: ReturnType; + let root: { [k: string]: BencodeValue }; + try { + meta = parseTorrent(buf); + root = bdecode(buf) as { [k: string]: BencodeValue }; + } catch { + throw new Error(BAD_TORRENT); + } + const info = root.info as { [k: string]: BencodeValue } | undefined; + const created = typeof root["creation date"] === "number" ? root["creation date"] : null; + return { + type: "torrent", + size, + name: meta.name, + hash: meta.hash, + infohashV1: meta.infohashV1, + infohashV2: meta.infohashV2, + private: meta.private, + totalSize: meta.totalSize, + fileCount: meta.files.length, + files: meta.files.slice(0, TORRENT_FILES).map((f) => ({ path: f.path, length: Math.max(0, f.length) })), + trackers: trackersOf(root), + comment: str(root["comment.utf-8"]) ?? str(root.comment), + createdBy: str(root["created by"]), + createdAt: created && created > 0 ? new Date(created * 1000).toISOString() : null, + pieceLength: typeof info?.["piece length"] === "number" ? info["piece length"] : null, + }; + } + const truncated = size > buf.length; + if (kind === "subtitle") { + if (ext === "sup") { + const all = parseSup(buf); + return { + type: "subtitle", + size, + format: "sup", + encoding: null, + cues: all.slice(0, CUE_LIMIT), + parsed: all.length, + truncated, + title: null, + }; + } + const { text, encoding } = decodeText(buf, truncated); + const ass = ext === "ass" || ext === "ssa"; + const parsed = ass ? parseAss(text) : { cues: parseSrtLike(text), title: null }; + const cues = [...parsed.cues].sort((a, b) => a.start - b.start); + return { + type: "subtitle", + size, + format: ext as "srt" | "vtt" | "ass" | "ssa", + encoding, + cues: cues.slice(0, CUE_LIMIT), + parsed: cues.length, + truncated, + title: parsed.title, + }; + } + const { text, encoding } = decodeText(buf, truncated); + return { type: "text", size, format: ext, encoding, text, truncated }; +} diff --git a/apps/server/src/modules/file-manager/preview.ts b/apps/server/src/modules/file-manager/preview.ts index 0f3e2d31..459640d2 100644 --- a/apps/server/src/modules/file-manager/preview.ts +++ b/apps/server/src/modules/file-manager/preview.ts @@ -1,21 +1,12 @@ import { open } from "node:fs/promises"; import { basename } from "node:path"; -import { BencodeError, type BencodeValue, bdecode, parseTorrent } from "@umt/adapters"; import type { FilePreview, FilesPreviewQuery } from "@umt/contracts"; import type { Principal } from "../../http/auth.ts"; +import { runSandbox, SandboxError } from "../../sandbox/client.ts"; +import { b64encode } from "../../sandbox/jobs.ts"; import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts"; -import { - CUE_LIMIT, - decodeText, - extOf, - IMAGE_MIME, - imageSize, - maskTracker, - parseAss, - parseSrtLike, - parseSup, - previewKindOf, -} from "./preview-parse.ts"; +import { BAD_TORRENT } from "./preview-build.ts"; +import { extOf, previewKindOf } from "./preview-parse.ts"; // GET /files/preview(FM-2):仅管理员会话;路径守卫同下载(realpath 在根内、符号链接目标也须在根内,打开前复核)。 // 只读文件头:文本/字幕 ≤ 1 MiB、图片头 512 KiB;.torrent 整个读但上限 4 MiB。只回 JSON,不回原始内容。 @@ -23,7 +14,6 @@ import { export const TEXT_PREVIEW_MAX = 1024 * 1024; const IMAGE_HEAD = 512 * 1024; const TORRENT_MAX = 4 * 1024 * 1024; -const TORRENT_FILES = 500; async function readHead(abs: string, max: number): Promise<{ buf: Uint8Array; size: number }> { const fh = await open(abs, "r"); @@ -44,17 +34,6 @@ async function readHead(abs: string, max: number): Promise<{ buf: Uint8Array; si } } -const str = (v: BencodeValue | undefined) => (v instanceof Uint8Array ? new TextDecoder().decode(v) : null); - -function trackersOf(root: { [k: string]: BencodeValue }): string[] { - const out: string[] = []; - const tiers = root["announce-list"]; - if (Array.isArray(tiers)) - for (const tier of tiers) for (const t of Array.isArray(tier) ? tier : [tier]) out.push(str(t) ?? ""); - out.push(str(root.announce) ?? ""); - return [...new Set(out.filter(Boolean).map(maskTracker))]; -} - export async function previewFile( d: FmDeps, principal: Principal | undefined, @@ -69,82 +48,21 @@ export async function previewFile( if (!kind) throw new FilesError("forbidden", `不支持预览 .${ext || "无扩展名"} 文件`); const real = await recheck(r.abs, r.rootReal); - if (kind === "image") { - const { buf, size } = await readHead(real, IMAGE_HEAD); - const dim = imageSize(buf); - return { - type: "image", - size, - mime: IMAGE_MIME[ext] ?? "application/octet-stream", - width: dim?.width ?? null, - height: dim?.height ?? null, - }; + const head = kind === "image" ? IMAGE_HEAD : kind === "torrent" ? TORRENT_MAX : TEXT_PREVIEW_MAX; + const { buf, size } = await readHead(real, head); + if (kind === "torrent" && size > TORRENT_MAX) + throw new FilesError("too_large", `.torrent 文件 ${size} 字节,超过预览上限 4 MiB`); + // 文件内容不可信:解析放进沙箱子进程(docs/adr/ADR-005-sandbox.md) + try { + return (await runSandbox( + "file.preview", + { kind, ext, base64: b64encode(buf), size }, + { timeoutMs: 15_000 }, + )) as FilePreview; + } catch (e) { + if (e instanceof SandboxError && e.kind === "job" && e.message.includes(BAD_TORRENT)) + throw new FilesError("bad_request", "不是有效的 .torrent 文件"); + if (e instanceof SandboxError) throw new FilesError("bad_request", `预览失败:${e.message}`); + throw e; } - - if (kind === "torrent") { - const { buf, size } = await readHead(real, TORRENT_MAX); - if (size > TORRENT_MAX) throw new FilesError("too_large", `.torrent 文件 ${size} 字节,超过预览上限 4 MiB`); - try { - const meta = parseTorrent(buf); - const root = bdecode(buf) as { [k: string]: BencodeValue }; - const info = root.info as { [k: string]: BencodeValue } | undefined; - const created = typeof root["creation date"] === "number" ? root["creation date"] : null; - return { - type: "torrent", - size, - name: meta.name, - hash: meta.hash, - infohashV1: meta.infohashV1, - infohashV2: meta.infohashV2, - private: meta.private, - totalSize: meta.totalSize, - fileCount: meta.files.length, - files: meta.files.slice(0, TORRENT_FILES).map((f) => ({ path: f.path, length: Math.max(0, f.length) })), - trackers: trackersOf(root), - comment: str(root["comment.utf-8"]) ?? str(root.comment), - createdBy: str(root["created by"]), - createdAt: created && created > 0 ? new Date(created * 1000).toISOString() : null, - pieceLength: typeof info?.["piece length"] === "number" ? info["piece length"] : null, - }; - } catch (e) { - if (e instanceof BencodeError) throw new FilesError("bad_request", "不是有效的 .torrent 文件"); - throw e; - } - } - - const { buf, size } = await readHead(real, TEXT_PREVIEW_MAX); - const truncated = size > buf.length; - - if (kind === "subtitle") { - if (ext === "sup") { - const all = parseSup(buf); - return { - type: "subtitle", - size, - format: "sup", - encoding: null, - cues: all.slice(0, CUE_LIMIT), - parsed: all.length, - truncated, - title: null, - }; - } - const { text, encoding } = decodeText(buf, truncated); - const ass = ext === "ass" || ext === "ssa"; - const parsed = ass ? parseAss(text) : { cues: parseSrtLike(text), title: null }; - const cues = [...parsed.cues].sort((a, b) => a.start - b.start); - return { - type: "subtitle", - size, - format: ext as "srt" | "vtt" | "ass" | "ssa", - encoding, - cues: cues.slice(0, CUE_LIMIT), - parsed: cues.length, - truncated, - title: parsed.title, - }; - } - - const { text, encoding } = decodeText(buf, truncated); - return { type: "text", size, format: ext, encoding, text, truncated }; } diff --git a/apps/server/src/modules/subtitles/service.ts b/apps/server/src/modules/subtitles/service.ts index dcc011e7..5d7dd7b0 100644 --- a/apps/server/src/modules/subtitles/service.ts +++ b/apps/server/src/modules/subtitles/service.ts @@ -14,32 +14,22 @@ import type { import type { SubtitleWriteTable } from "@umt/db"; import { type DefaultSubtitleLang, - decodeSubtitle, detectSubtitleLanguage, isSidecarOf, pairSubtitle, type SubtitleExt, - subtitleEpisode, subtitleExtOf, subtitleFileName, } from "@umt/domain"; -import { unzipSync } from "fflate"; import type { Selectable } from "kysely"; import { dataPaths } from "../../config.ts"; import { ApiException } from "../../http/errors.ts"; +import { runSandbox, SandboxError } from "../../sandbox/client.ts"; import type { ServerContext } from "../types.ts"; // 补字幕(docs/plan/subtitles.md;对照 MP 插件「海拉鲁字幕大师」upload / matching / subtitle_writer): // 解析(编码转 UTF-8、语言、季集)→ 配对到作品的视频 → 写到视频同目录(主干名 + 语言后缀)→ 登记字幕资产 + 历史,可撤销。 -const MAX_FILE_BYTES = 10 * 1024 * 1024; -const MAX_ARCHIVE_ENTRIES = 300; - -const b64 = (bytes: Uint8Array) => { - let s = ""; - for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); - return btoa(s); -}; const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); const fileName = (p: string) => p.replace(/\\/g, "/").split("/").pop() ?? p; @@ -133,60 +123,25 @@ export async function parseSubtitles( episode: t.episode, })) : []; - const items: SubtitleParsedItem[] = []; - const skipped: SubtitleParseResponse["skipped"] = []; - const one = (name: string, bytes: Uint8Array, archive: string | null) => { - const ext = subtitleExtOf(name); - if (!ext) return skipped.push({ name, reason: "不是字幕文件" }); - if (bytes.length > MAX_FILE_BYTES) return skipped.push({ name, reason: "文件超过 10 MB" }); - const { text, encoding } = decodeSubtitle(bytes); - const ep = subtitleEpisode(name); - items.push({ - id: crypto.randomUUID(), - name, - archive, - ext, - language: detectSubtitleLanguage(name, text) as SubtitleLanguage, - season: ep.season, - seasonInferred: ep.seasonInferred, - episode: ep.episode, - encoding, - sizeBytes: bytes.length, - content: b64(new TextEncoder().encode(text)), - targetLocationId: targets.length ? pairSubtitle(ep, targets) : null, - }); - }; - for (const f of files) { - let bytes: Uint8Array; - try { - bytes = unb64(f.base64); - } catch { - skipped.push({ name: f.name, reason: "内容不是有效的 base64" }); - continue; - } - if (/\.zip$/i.test(f.name)) { - let entries: Record; - try { - let n = 0; - entries = unzipSync(bytes, { - filter: (e) => - !e.name.endsWith("/") && - !!subtitleExtOf(e.name) && - e.originalSize <= MAX_FILE_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 if (/\.(rar|7z)$/i.test(f.name)) { - skipped.push({ name: f.name, reason: "暂不支持 rar / 7z,请解压后上传或改用 zip" }); - } else one(f.name, bytes, null); + // 上传 / 下载的字幕与压缩包不可信:解压、解码、识别都在沙箱子进程里做(docs/adr/ADR-005-sandbox.md) + let parsed: Awaited>>; + try { + parsed = await runSandbox("subtitle.parse", { files }, { timeoutMs: 30_000 }); + } catch (e) { + if (e instanceof SandboxError) throw new ApiException("bad_request", `字幕解析失败:${e.message}`); + throw e; } - return { items, skipped }; + const items: SubtitleParsedItem[] = parsed.items.map((it) => { + const ep = { season: it.season, seasonInferred: it.seasonInferred, episode: it.episode }; + return { + ...it, + id: crypto.randomUUID(), + ext: it.ext as SubtitleExt, + language: it.language as SubtitleLanguage, + targetLocationId: targets.length ? pairSubtitle(ep, targets) : null, + }; + }); + return { items, skipped: parsed.skipped }; } // ── 写入 ── diff --git a/apps/server/src/sandbox/client.ts b/apps/server/src/sandbox/client.ts new file mode 100644 index 00000000..81053d1c --- /dev/null +++ b/apps/server/src/sandbox/client.ts @@ -0,0 +1,162 @@ +// 沙箱调用(docs/adr/ADR-005-sandbox.md):每次一个子进程,权限全部撤销;超时 SIGKILL,子进程实际内存(RSS)超限 SIGKILL。 +// 起子进程方式同 fsproxy:deno CLI → `deno run worker.ts`(开发 / 测试);编译二进制 → `<自身> --role sandbox`。 +// 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。 +import { basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts"; + +export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn"; + +export class SandboxError extends Error { + constructor( + readonly kind: SandboxErrorKind, + message: string, + ) { + super(message); + this.name = "SandboxError"; + } +} + +export interface SandboxOptions { + /** 缺省 20 s */ + timeoutMs?: number; + /** 子进程内存上限,缺省 512 MB */ + memoryMb?: number; + signal?: AbortSignal; +} + +const WORKER_PATH = fileURLToPath(new URL("./worker.ts", import.meta.url)); +let inProcessReason: string | null = null; +const stats = { spawned: 0, timeouts: 0, memoryKills: 0, crashes: 0 }; + +export function sandboxStats() { + return { ...stats, inProcessReason }; +} + +function command(memoryMb: number): { cmd: string; args: string[] } { + const exe = Deno.execPath(); + if (/^deno(\.exe)?$/.test(basename(exe))) + return { + cmd: exe, + args: [ + "run", + "-A", + "--quiet", + "--no-prompt", // V8 堆上限只作兜底(设为两倍):主要靠下面的 RSS 看门狗,编译产物不认 V8 参数,两种模式一致 + `--v8-flags=--max-old-space-size=${memoryMb * 2}`, + WORKER_PATH, + ], + }; + return { cmd: exe, args: ["--role", "sandbox"] }; +} + +/** 子进程实际内存(KB);读不到返回 null */ +async function rssKb(pid: number): Promise { + try { + const s = await Deno.readTextFile(`/proc/${pid}/status`); + const m = /VmRSS:\s+(\d+)\s+kB/.exec(s); + return m ? Number(m[1]) : null; + } catch { + return null; + } +} + +export async function runSandbox( + job: J, + input: SandboxInput, + opts: SandboxOptions = {}, +): Promise> { + const timeoutMs = opts.timeoutMs ?? 20_000; + const memoryMb = opts.memoryMb ?? 512; + opts.signal?.throwIfAborted(); + if (inProcessReason) return runInProcess(job, input, timeoutMs) as Promise>; + const { cmd, args } = command(memoryMb); + let child: Deno.ChildProcess; + try { + child = new Deno.Command(cmd, { + args, + stdin: "piped", + stdout: "piped", + stderr: "piped", + env: {}, + clearEnv: true, + }).spawn(); + } catch (e) { + inProcessReason = (e as Error).message; + console.warn(`[sandbox] 起不来子进程,退回进程内执行:${inProcessReason}`); + return runInProcess(job, input, timeoutMs) as Promise>; + } + stats.spawned += 1; + let killedFor: SandboxErrorKind | null = null; + const kill = (why: SandboxErrorKind) => { + if (killedFor) return; + killedFor = why; + try { + child.kill("SIGKILL"); + } catch { + // 已退出 + } + }; + const timer = setTimeout(() => kill("timeout"), timeoutMs); + const watch = setInterval(async () => { + const kb = await rssKb(child.pid); + if (kb !== null && kb > memoryMb * 1024) kill("memory"); + }, 100); + const onAbort = () => kill("crash"); + opts.signal?.addEventListener("abort", onAbort, { once: true }); + try { + const w = child.stdin.getWriter(); + await w.write(new TextEncoder().encode(JSON.stringify({ job, input }))).catch(() => undefined); + await w.close().catch(() => undefined); + const [status, stdout, stderr] = await Promise.all([ + child.status, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (killedFor === "timeout") { + stats.timeouts += 1; + throw new SandboxError("timeout", `沙箱任务 ${job} 超过 ${timeoutMs} ms 未完成,已强制结束`); + } + if (killedFor === "memory" || /out of memory|heap limit/i.test(stderr)) { + stats.memoryKills += 1; + throw new SandboxError("memory", `沙箱任务 ${job} 内存超过 ${memoryMb} MB,已强制结束`); + } + opts.signal?.throwIfAborted(); + const line = stdout.trim().split("\n").pop() ?? ""; + let msg: { ok: boolean; result?: unknown; error?: string }; + try { + msg = JSON.parse(line); + } catch { + stats.crashes += 1; + throw new SandboxError("crash", `沙箱任务 ${job} 异常退出(code ${status.code}):${stderr.trim().slice(-300)}`); + } + if (!msg.ok) throw new SandboxError("job", msg.error ?? "沙箱任务失败"); + return msg.result as SandboxOutput; + } finally { + clearTimeout(timer); + clearInterval(watch); + opts.signal?.removeEventListener("abort", onAbort); + } +} + +async function runInProcess( + job: J, + input: SandboxInput, + timeoutMs: number, +): Promise { + const fn = SANDBOX_JOBS[job] as (i: SandboxInput) => unknown; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve().then(() => fn(input)), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new SandboxError("timeout", `沙箱任务 ${job} 超时(进程内)`)), timeoutMs); + }), + ]); + } catch (e) { + if (e instanceof SandboxError) throw e; + throw new SandboxError("job", `${(e as Error).name}: ${(e as Error).message}`); + } finally { + clearTimeout(timer); + } +} diff --git a/apps/server/src/sandbox/install.ts b/apps/server/src/sandbox/install.ts new file mode 100644 index 00000000..b408eda3 --- /dev/null +++ b/apps/server/src/sandbox/install.ts @@ -0,0 +1,16 @@ +import { BencodeError, setTorrentParser, type TorrentMeta } from "@umt/adapters"; +import { runSandbox, SandboxError } from "./client.ts"; +import { b64encode } from "./jobs.ts"; + +/** 常驻角色启动时调用:adapters 里的不可信字节解析改走沙箱子进程(docs/adr/ADR-005-sandbox.md) */ +export function installSandboxParsers(): void { + setTorrentParser(async (bytes) => { + try { + return (await runSandbox("torrent.parse", { base64: b64encode(bytes) }, { timeoutMs: 15_000 })) as TorrentMeta; + } catch (e) { + // 解析失败、超时、内存超限都按「不是有效种子」处理,调用方据此拒收 + if (e instanceof SandboxError) throw new BencodeError(e.message); + throw e; + } + }); +} diff --git a/apps/server/src/sandbox/jobs.ts b/apps/server/src/sandbox/jobs.ts new file mode 100644 index 00000000..b4c6d47b --- /dev/null +++ b/apps/server/src/sandbox/jobs.ts @@ -0,0 +1,115 @@ +// 沙箱任务表(docs/adr/ADR-005-sandbox.md):只放「外部不可信字节 → 结构化数据」的纯函数。 +// 子进程加载完本表后撤销全部权限(文件 / 网络 / 环境变量 / 子进程),所以任务里不能有任何 I/O; +// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。 +import { parseTorrent } from "@umt/adapters"; +import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain"; +import { unzipSync } from "fflate"; +import { buildPreview } from "../modules/file-manager/preview-build.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; +} + +/** 字幕 / zip:解压、解码转 UTF-8、识别语言与季集 */ +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; + } + if (/\.zip$/i.test(f.name)) { + let entries: Record; + try { + let n = 0; + entries = unzipSync(bytes, { + filter: (e) => + !e.name.endsWith("/") && + !!subtitleExtOf(e.name) && + e.originalSize <= 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 if (/\.(rar|7z)$/i.test(f.name)) { + skipped.push({ name: f.name, reason: "暂不支持 rar / 7z,请解压后上传或改用 zip" }); + } 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 }; +} + +export const SANDBOX_JOBS = { + "subtitle.parse": subtitleParse, + "torrent.parse": torrentParse, + "file.preview": buildPreview, + "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>; diff --git a/apps/server/src/sandbox/worker.ts b/apps/server/src/sandbox/worker.ts new file mode 100644 index 00000000..8f234f72 --- /dev/null +++ b/apps/server/src/sandbox/worker.ts @@ -0,0 +1,32 @@ +// 沙箱子进程入口:先加载任务表,再撤销全部权限,然后从 stdin 读一条 {job, input},把 {ok, result | error} 写到 stdout。 +// 开发 / 测试由 deno run 直接跑本文件;编译二进制以 `<自身> --role sandbox` 拉起(main.ts 在加载其余模块前分派)。 +import { SANDBOX_JOBS } from "./jobs.ts"; + +const PERMISSIONS = ["read", "write", "net", "env", "run", "ffi", "sys"] as const; + +async function writeAll(bytes: Uint8Array) { + let off = 0; + while (off < bytes.length) off += await Deno.stdout.write(bytes.subarray(off)); +} + +export async function sandboxWorkerMain(): Promise { + for (const name of PERMISSIONS) { + try { + Deno.permissions.revokeSync({ name } as Deno.PermissionDescriptor); + } catch { + // 运行时不认识的权限名:跳过 + } + } + let out: unknown; + try { + const req = JSON.parse(await new Response(Deno.stdin.readable).text()) as { job: string; input: unknown }; + const fn = (SANDBOX_JOBS as Record unknown>)[req.job]; + if (!fn) throw new Error(`没有这个沙箱任务:${req.job}`); + out = { ok: true, result: await fn(req.input) }; + } catch (e) { + out = { ok: false, error: `${(e as Error).name}: ${(e as Error).message}`.slice(0, 2000) }; + } + await writeAll(new TextEncoder().encode(`${JSON.stringify(out)}\n`)); +} + +if (import.meta.main) await sandboxWorkerMain(); diff --git a/apps/server/test/sandbox/sandbox.test.ts b/apps/server/test/sandbox/sandbox.test.ts new file mode 100644 index 00000000..e5c255dd --- /dev/null +++ b/apps/server/test/sandbox/sandbox.test.ts @@ -0,0 +1,51 @@ +import { buildFakeTorrent } from "@umt/testkit"; +import { zipSync } from "fflate"; +import { describe, expect, it } from "vitest"; +import { runSandbox, SandboxError } from "../../src/sandbox/client.ts"; +import { b64encode } from "../../src/sandbox/jobs.ts"; + +// 沙箱(docs/adr/ADR-005-sandbox.md):子进程撤销全部权限;卡死超时强杀;内存超限强杀;任务错误原样带回 +const enc = (s: string) => new TextEncoder().encode(s); + +describe("沙箱子进程", () => { + it("正常返回;子进程里读文件被拒绝", async () => { + expect(await runSandbox("self.test", { mode: "ok" })).toEqual({ ok: true }); + await expect(runSandbox("self.test", { mode: "io" })).rejects.toMatchObject({ kind: "job", message: /NotCapable/ }); + }); + + it("卡死:超时后强杀,不拖住调用方", async () => { + const t0 = Date.now(); + const e = await runSandbox("self.test", { mode: "hang" }, { timeoutMs: 800 }).catch((x) => x); + expect(e).toBeInstanceOf(SandboxError); + expect(e.kind).toBe("timeout"); + expect(Date.now() - t0).toBeLessThan(5000); + }); + + it("吃内存:超过上限强杀", async () => { + const e = await runSandbox("self.test", { mode: "memory" }, { memoryMb: 128, timeoutMs: 20_000 }).catch((x) => x); + expect(e).toBeInstanceOf(SandboxError); + expect(e.kind).toBe("memory"); + }); + + it("任务自己抛错:kind = job,带原因", async () => { + await expect(runSandbox("self.test", { mode: "throw" })).rejects.toMatchObject({ + kind: "job", + message: /任务自己抛的错/, + }); + }); + + it("字幕:zip 解压、语言与季集;种子:文件清单", async () => { + const zip = zipSync({ "a/Show.S01E03.chs.srt": enc("1\n00:00:01,000 --> 00:00:02,000\n你好\n") }); + const r = await runSandbox("subtitle.parse", { files: [{ name: "x.zip", base64: b64encode(zip) }] }); + expect(r.items[0]).toMatchObject({ + name: "a/Show.S01E03.chs.srt", + archive: "x.zip", + language: "zh-Hans", + episode: 3, + }); + const t = buildFakeTorrent({ name: "Show.S01E01.mkv" }); + const f = await runSandbox("torrent.parse", { base64: b64encode(t.bytes) }); + expect(f.files.map((x) => x.path)).toEqual(["Show.S01E01.mkv"]); + expect(f.hash).toBe(t.infoHash); + }); +}); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 2d0b2852..5a1ead4d 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -24,6 +24,8 @@ const UNIT = [ ]; const SERIAL = [ + // 起真实沙箱子进程(内存 / 超时强杀) + "test/sandbox/**", "test/e2e/**/*.test.ts", "test/auth-settings/admin-api.test.ts", "test/auth-settings/engine-retry.test.ts", diff --git a/docs/adr/ADR-005-sandbox.md b/docs/adr/ADR-005-sandbox.md new file mode 100644 index 00000000..623466d2 --- /dev/null +++ b/docs/adr/ADR-005-sandbox.md @@ -0,0 +1,38 @@ +# ADR-005:不可信字节在沙箱子进程里解析 + +- 状态:采纳(2026-10-03,用户确认整体规划)。 +- 关联:ADR-004(Deno 运行时与权限)、`packages/adapters/src/fs/fsproxy.ts`(同一子进程模式)、docs/plan/subtitles.md。 + +## 背景 + +我们执行的都是自己的代码,但要解析大量**外部的不可信数据**:站点下载的种子、字幕与压缩包、文件管理里的文件、站点网页与 RSS、验证码图片。风险不是代码逃逸,而是畸形 / 恶意数据让解析器卡死、撑爆内存,或者触发解析库的漏洞,把整个服务拖垮。 + +Deno 官方对不可信代码的建议是多层防护:启动即收紧权限(`--frozen`)、权限更少的 Web Worker、操作系统级沙箱、VM。我们在 Deno 2.9.7 上实测: + +| 方案 | 权限隔离 | 卡死后能否收回 | 内存上限 | +| --- | --- | --- | --- | +| Worker `deno.permissions: "none"` | 可以,但要 `--unstable-worker-options` | 不能:`terminate()` 返回后,死循环的线程仍占满 CPU | 不支持 | +| 子进程,启动后 `Deno.permissions.revokeSync` 撤销全部权限 | 可以(稳定 API) | 可以:SIGKILL | 可以:父进程读 `/proc//status` 的 RSS,超限 SIGKILL | + +编译产物不认 `DENO_V8_FLAGS`,所以内存上限统一靠父进程的 RSS 看门狗;开发模式另加两倍的 V8 堆上限兜底。 + +## 决定 + +1. **沙箱 = 一次性子进程**(`apps/server/src/sandbox/`),起法同 fsproxy:deno CLI 下 `deno run worker.ts`,编译二进制下 `<自身> --role sandbox`(`main.ts` 在加载其余模块前分派)。子进程加载任务表后撤销 read / write / net / env / run / ffi / sys 全部权限,从 stdin 读一条 `{job, input}`,stdout 写回 `{ok, result | error}`;清空环境变量启动。 +2. **调用**:`runSandbox(任务名, 输入, { timeoutMs, memoryMb })`,缺省 20 s / 512 MB;失败分类 `timeout / memory / crash / job / spawn`。起不来子进程时退回进程内执行并记日志(仍有超时,但卡死无法收回)。 +3. **任务表**(`sandbox/jobs.ts`)只放纯函数:字节或文本进、可 JSON 化的数据出,二进制用 base64。 +4. **adapters 不依赖服务端**:不可信字节的解析入口做成可替换的(`packages/adapters/src/isolation.ts`,缺省进程内),服务端启动时 `installSandboxParsers()` 换成沙箱实现。 +5. **约定**:新功能凡是解析外部下载 / 上传的文件或字节,一律走沙箱任务(写进 CLAUDE.md)。 + +## 范围与顺序 + +- 已迁移:种子解析(站点下载校验、qB 提交前算 hash、浏览器下载核验、公开 BT 站详情读文件列表)、文件管理预览(种子 / 字幕 / 文本 / 图片头)、补字幕(解码、zip 解压、语言与季集识别)。 +- 随补字幕第 2 步:rar / 7z 解压、SubHD / 字幕库验证码识别。 +- 第二批:站点网页、RSS、网页抓取的 HTML / XML 解析。调用频率高,要先把适配器里「请求」与「解析」拆开,再接常驻子进程池(复用进程、出事重启),避免每次约 50 ms 的启动开销。 +- 已隔离、不动:ffprobe(外部程序)、文件操作(fsworker)。 + +## 代价 + +- 每次调用起一个进程,约 50 ms;一次性大文件(字幕包、种子)可以忽略,高频解析留给常驻池。 +- 输入输出要 JSON 化,二进制 base64 会多约三分之一体积;单次输入有上限(字幕 10 MB / 文件,种子 4 MB)。 +- 以后 Deno 的 Worker 权限转正、`terminate()` 能中断卡死线程,常驻池可以换成 Worker,调用接口不变。 diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 352c659e..4b83bba7 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -3,6 +3,7 @@ export const PACKAGE_NAME = "@umt/adapters"; export * from "./browser/index.ts"; export * from "./emby/index.ts"; export * from "./fs/index.ts"; +export * from "./isolation.ts"; export * from "./meta/index.ts"; export * from "./qbittorrent/index.ts"; export * from "./rss/index.ts"; diff --git a/packages/adapters/src/isolation.ts b/packages/adapters/src/isolation.ts new file mode 100644 index 00000000..6dfc95ad --- /dev/null +++ b/packages/adapters/src/isolation.ts @@ -0,0 +1,29 @@ +// 不可信字节的解析入口(docs/adr/ADR-005-sandbox.md):缺省在进程内解析;服务端启动时用 setTorrentParser 换成沙箱子进程实现。 +// adapters 不依赖服务端,测试与单独使用时照常进程内解析。 +import { BencodeError, parseTorrent, type TorrentMeta } from "./qbittorrent/torrent.ts"; + +export type TorrentParser = (bytes: Uint8Array) => Promise; + +let parser: TorrentParser = async (bytes) => parseTorrent(bytes); + +export function setTorrentParser(p: TorrentParser): void { + parser = p; +} + +/** 解析 .torrent;不是有效种子抛 BencodeError */ +export function parseTorrentIsolated(bytes: Uint8Array): Promise { + return parser(bytes); +} + +/** 校验是 .torrent 并取 v1 infohash 与名称;不是种子返回 null(取代进程内的 inspectTorrent) */ +export async function inspectTorrentIsolated( + bytes: Uint8Array, +): Promise<{ infoHash: string; name: string | null } | null> { + try { + const m = await parser(bytes); + return { infoHash: m.infohashV1 ?? m.hash, name: m.name || null }; + } catch (e) { + if (e instanceof BencodeError) return null; + throw e; + } +} diff --git a/packages/adapters/src/qbittorrent/client.ts b/packages/adapters/src/qbittorrent/client.ts index 1c9bed35..15cda299 100644 --- a/packages/adapters/src/qbittorrent/client.ts +++ b/packages/adapters/src/qbittorrent/client.ts @@ -5,10 +5,11 @@ * 叠加:多实例、错误分类(DownloaderError)、提交幂等(本地 infohash 预查 + 收单超时按 hash 回查)。 */ import { randomBytes } from "node:crypto"; +import { parseTorrentIsolated } from "../isolation.ts"; import { DownloaderError, type DownloaderErrorKind } from "./errors.ts"; import { assertSafeToDeleteData, type SafeDeleteCheck } from "./safe-delete.ts"; import { isDownloadComplete, mapQbState, type QbObservedState } from "./state.ts"; -import { BencodeError, parseMagnet, parseTorrent } from "./torrent.ts"; +import { BencodeError, parseMagnet } from "./torrent.ts"; export interface QbInstanceConfig { /** 本系统内的下载器实例 ID(downloader_instance.id) */ @@ -760,7 +761,7 @@ export class QbClient implements DownloaderClient { const src = req.source; if (src.kind === "file") { try { - hash = parseTorrent(src.bytes).hash; + hash = (await parseTorrentIsolated(src.bytes)).hash; } catch (e) { if (e instanceof BencodeError) return { kind: "rejected", reason: `invalid torrent: ${e.message}`, hash: null }; throw e; diff --git a/packages/adapters/src/sites/mteam/adapter.ts b/packages/adapters/src/sites/mteam/adapter.ts index 658b292c..c608216e 100644 --- a/packages/adapters/src/sites/mteam/adapter.ts +++ b/packages/adapters/src/sites/mteam/adapter.ts @@ -9,6 +9,8 @@ * - `app/modules/indexer/parser/mtorrent.py`(api/member/profile)→ `healthCheck` * 详情 `/api/torrent/detail` 为本项目扩展(MP spider 无详情调用,字段结构参考 MovieClaw mteam.py,data-ref)。 */ + +import { inspectTorrentIsolated } from "../../isolation.ts"; import { redactUrl } from "../../rss/redact.ts"; import { buildHttpContext, type SiteAdapterDeps } from "../deps.ts"; import { pageFromBbcode } from "../detail-text.ts"; @@ -26,7 +28,7 @@ import { type SiteRelease, type SiteReleaseDetail, } from "../types.ts"; -import { extractDoubanId, inspectTorrent, parseSiteTime } from "../util.ts"; +import { extractDoubanId, parseSiteTime } from "../util.ts"; import { mteamPost } from "./client.ts"; import { type MteamDefinition, mteamApiBase } from "./definition.ts"; import { type MteamTorrentJson, mteamSearchParams, parseMteamResult } from "./spider.ts"; @@ -192,7 +194,7 @@ export function createMteamAdapter(input: MteamDefinition, deps: SiteAdapterDeps ); } const bytes = await readBody(http, res); - const info = inspectTorrent(bytes); + const info = await inspectTorrentIsolated(bytes); if (!info) throw new SiteError(def.id, "parse_error", `下载 ${id}:返回内容不是 .torrent`); return { kind: "torrent", diff --git a/packages/adapters/src/sites/spider/adapter.ts b/packages/adapters/src/sites/spider/adapter.ts index becf4251..33af4865 100644 --- a/packages/adapters/src/sites/spider/adapter.ts +++ b/packages/adapters/src/sites/spider/adapter.ts @@ -11,6 +11,8 @@ * 本项目调整:登录页不再"返回空列表",而是抛 `auth_expired`(REQ §4.7.1 不展示空列表冒充无内容); * 3xx 不自动跟随(避免把 cookie 带到站外,并把跳转 login 识别为失效);凭证经 SecretResolver 注入。 */ + +import { inspectTorrentIsolated } from "../../isolation.ts"; import { redactText } from "../../rss/redact.ts"; import { buildHttpContext, type SiteAdapterDeps } from "../deps.ts"; import { readBody, readText, siteFetch } from "../http.ts"; @@ -25,7 +27,6 @@ import { type SitePage, type SiteReleaseDetail, } from "../types.ts"; -import { inspectTorrent } from "../util.ts"; import { parseDetailPage } from "./detail.ts"; import { filesFromFileListHtml } from "./detail-page.ts"; import { spiderTorrentToRelease } from "./map.ts"; @@ -288,7 +289,7 @@ export function createSpiderSiteAdapter(input: IndexerDefinition, deps: SiteAdap }); } const bytes = await readBody(http, res); - const info = inspectTorrent(bytes); + const info = await inspectTorrentIsolated(bytes); if (!info) { // 站点在 cookie 失效时常返回 200 + 登录页 HTML const $ = loadHtml(new TextDecoder().decode(bytes.subarray(0, 200_000))); diff --git a/packages/adapters/src/sites/spiders/common.ts b/packages/adapters/src/sites/spiders/common.ts index 6b5f8c6c..30d9735b 100644 --- a/packages/adapters/src/sites/spiders/common.ts +++ b/packages/adapters/src/sites/spiders/common.ts @@ -11,10 +11,11 @@ * - `temporal.format_timestamp` → `formatTimestampTz`;`urllib.parse.quote` → `pyQuote` * - 各 spider `__process_response` 的错误分支 → `apiRequest`(HTTP 401/403 → auth_invalid)+ `businessError` */ + +import { inspectTorrentIsolated } from "../../isolation.ts"; import { redactText, redactUrl } from "../../rss/redact.ts"; import { readBody, readText, siteFetch } from "../http.ts"; import { type DownloadTarget, SiteError, type SiteErrorKind } from "../types.ts"; -import { inspectTorrent } from "../util.ts"; import type { SpiderContext } from "./types.ts"; /** 下载链接中需要脱敏的额外参数名(HDDolby downhash 等效 passkey) */ @@ -353,7 +354,7 @@ export async function downloadTorrentFile( }); } const bytes = await readBody(ctx.http, res); - const info = inspectTorrent(bytes); + const info = await inspectTorrentIsolated(bytes); if (!info) throw new SiteError(ctx.def.id, "parse_error", `下载 ${safe}:返回内容不是 .torrent`); return { kind: "torrent", bytes, infoHash: info.infoHash, name: info.name, fileName: null, verifiedAt }; } diff --git a/packages/adapters/src/sites/spiders/public-detail.ts b/packages/adapters/src/sites/spiders/public-detail.ts index 1a844ed2..7843a2e7 100644 --- a/packages/adapters/src/sites/spiders/public-detail.ts +++ b/packages/adapters/src/sites/spiders/public-detail.ts @@ -3,7 +3,7 @@ * 简介里的 IMDb / 豆瓣 / TMDB / bgm.tv 链接取作识别证据;页面没有文件清单的站(蜜柑、AniBT)下载种子读文件列表。 */ import { load } from "cheerio"; -import { parseTorrent } from "../../qbittorrent/torrent.ts"; +import { parseTorrentIsolated } from "../../isolation.ts"; import { emptyDetailPage, extractTmdb, MAX_FILES, MAX_SCREENSHOTS, screenshotUrl } from "../detail-text.ts"; import { blockText, imdbFromText } from "../spider/detail-page.ts"; import { SiteError, type SiteReleaseDetail } from "../types.ts"; @@ -59,7 +59,7 @@ export async function specializedDetail( try { const t = await spider.resolveDownload(ctx, siteReleaseId, enclosure); if (t.kind === "torrent") - files = parseTorrent(t.bytes).files.map((f) => ({ name: f.path, sizeBytes: f.length || null })); + files = (await parseTorrentIsolated(t.bytes)).files.map((f) => ({ name: f.path, sizeBytes: f.length || null })); } catch { // 种子取不到只是没有文件清单,不影响详情 }