feat(sandbox): 不可信字节在沙箱子进程里解析(撤销权限、超时与内存强杀);种子、文件预览、字幕解析迁入

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 01:44:31 +08:00
1 parent 1a76d2d700
commit eeec91e892
21 files changed
+632 -178

No files matched your search

+1
View File
@@ -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";
+29
View File
@@ -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<TorrentMeta>;
let parser: TorrentParser = async (bytes) => parseTorrent(bytes);
export function setTorrentParser(p: TorrentParser): void {
parser = p;
}
/** 解析 .torrent;不是有效种子抛 BencodeError */
export function parseTorrentIsolated(bytes: Uint8Array): Promise<TorrentMeta> {
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;
}
}
+3 -2
View File
@@ -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;
+4 -2
View File
@@ -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",
@@ -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)));
@@ -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 };
}
@@ -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 {
// 种子取不到只是没有文件清单,不影响详情
}