feat(adapters,testkit): guarded qB data deletion (B7) and mock qB file materialization (B8)

adapters: assertSafeToDeleteData({contentPath, libraryRoots, importVerified}) rejects
paths inside/equal to/above any library root, relative or root paths, empty roots and
unverified imports; QbClient.delete({deleteFiles:true}) requires a per-hash
SafeDeleteCheck and throws DownloaderError('unsafe_delete') before sending anything.

testkit: mock qB complete(hash, {files, writeFiles}) writes random-content files under
save_path (opt-in, size-capped, traversal-safe); deleteFiles=true removes them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 19:22:56 +08:00
1 parent 42ecc93fa5
commit fa9221086b
9 files changed
+371 -9

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
# qbittorrent
qBittorrent WebUI API v2 适配(`QbClient` 实现 `DownloaderClient`,按实例创建,`DownloaderRegistry` 管多实例):Cookie 登录与会话过期自动重登、API Key(Bearer)、`torrents/add`(磁力/URL/.torrent,savepath/category/tags/paused+stopped/rename/contentLayout,autoTMM 固定关闭)、`torrents/info`(hashes/tag/category/filter,多标签子集过滤)、`files`、`filePrio`、pause/resume(5.x 自动切到 stop/start)、标签增删与定义删除、`delete`(`deleteFiles` 必须显式)、`setForceStart`/`recheck`/`setLocation`/`setCategory`/限速与分享限制、只读偏好、`sync/maindata`、`app/version`。错误统一为 `DownloaderError`(auth/network/timeout/notfound/rate/rejected/protocol),读请求按配置重试,写请求不自动重试。`submit()` 本地算 infohash(bencode 自写)→ 预查已存在 → 提交 → 按 hash 或探针标签确认;收单超时/断连时先回查再给结论(`added`/`already_exists`/`accepted_unconfirmed`/`rejected`/`unreachable`)。状态映射与完成判定对照 MoviePilot,未知状态不兜底为完成。
qBittorrent WebUI API v2 适配(`QbClient` 实现 `DownloaderClient`,按实例创建,`DownloaderRegistry` 管多实例):Cookie 登录与会话过期自动重登、API Key(Bearer)、`torrents/add`(磁力/URL/.torrent,savepath/category/tags/paused+stopped/rename/contentLayout,autoTMM 固定关闭)、`torrents/info`(hashes/tag/category/filter,多标签子集过滤)、`files`、`filePrio`、pause/resume(5.x 自动切到 stop/start)、标签增删与定义删除、`delete`(`deleteFiles` 必须显式;`deleteFiles:true` 必须给每个 hash 附 `SafeDeleteCheck`,未通过 `assertSafeToDeleteData`——路径在任何库根内/是库根上级、未验证入库、库根为空——即抛 `unsafe_delete` 且不发请求)、`setForceStart`/`recheck`/`setLocation`/`setCategory`/限速与分享限制、只读偏好、`sync/maindata`、`app/version`。错误统一为 `DownloaderError`(auth/network/timeout/notfound/rate/rejected/protocol/unsafe_delete),读请求按配置重试,写请求不自动重试。`submit()` 本地算 infohash(bencode 自写)→ 预查已存在 → 提交 → 按 hash 或探针标签确认;收单超时/断连时先回查再给结论(`added`/`already_exists`/`accepted_unconfirmed`/`rejected`/`unreachable`)。状态映射与完成判定对照 MoviePilot,未知状态不兜底为完成。
仅对 mock 与官方 API 文档验证,未连接真实实例。
+22 -3
View File
@@ -6,6 +6,7 @@
*/
import { randomBytes } from "node:crypto";
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";
@@ -161,11 +162,19 @@ export interface DownloaderClient {
deleteTagDefinitions(tags: string[]): Promise<void>;
/** 从任务移除标签并删除全局定义;hashes 为 null 时只删定义(对照 delete_torrents_tag) */
deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise<void>;
/** deleteFiles 必须显式给出;删数据前的引用检查由上层 assertSafeToDeleteData 负责 */
delete(hashes: string[], opts: { deleteFiles: boolean }): Promise<void>;
/**
* deleteFiles 必须显式给出。deleteFiles=true 时每个 hash 都要在 safety 里给出检查输入,
* 任一未通过 assertSafeToDeleteData 即抛 DownloaderError('unsafe_delete'),请求不会发出。
*/
delete(hashes: string[], opts: DeleteOptions): Promise<void>;
mainData(rid?: number): Promise<QbMainData>;
}
/** 删除选项:删数据必须附带逐个 hash 的安全检查输入(键为 hash,大小写不敏感) */
export type DeleteOptions =
| { deleteFiles: false }
| { deleteFiles: true; safety: Readonly<Record<string, SafeDeleteCheck>> };
export interface TorrentLimits {
/** KB/s,0 不限速 */
uploadLimitKBps?: number;
@@ -586,9 +595,19 @@ export class QbClient implements DownloaderClient {
return this.json<Record<string, unknown>>("transfer/info");
}
async delete(hashes: string[], opts: { deleteFiles: boolean }): Promise<void> {
async delete(hashes: string[], opts: DeleteOptions): Promise<void> {
if (typeof opts?.deleteFiles !== "boolean") throw new TypeError("deleteFiles must be explicit");
if (!hashes.length) return;
if (opts.deleteFiles) {
const safety = new Map(Object.entries(opts.safety ?? {}).map(([h, c]) => [h.toLowerCase(), c]));
for (const h of hashes) {
const check = safety.get(h.toLowerCase());
if (!check) {
throw this.err("unsafe_delete", `拒绝删除数据: ${h} 缺少安全检查输入`, "torrents/delete");
}
assertSafeToDeleteData(check, this.instanceId);
}
}
await this.post("torrents/delete", { hashes: hashes.join("|"), deleteFiles: String(opts.deleteFiles) });
}
+2 -1
View File
@@ -6,7 +6,8 @@ export type DownloaderErrorKind =
| "notfound" // 目标 hash 不存在
| "rate" // 429 / 限流
| "rejected" // 下载器明确拒绝(如 torrents/add 返回 Fails.、415 非法种子)
| "protocol"; // 响应不符合预期(版本不兼容、5xx 等)
| "protocol" // 响应不符合预期(版本不兼容、5xx 等)
| "unsafe_delete"; // 删数据前的安全检查未通过(路径落在媒体库内/未验证入库),请求没有发出
export class DownloaderError extends Error {
override readonly name = "DownloaderError";
@@ -1,5 +1,6 @@
export * from "./client.ts";
export * from "./errors.ts";
export * from "./paths.ts";
export * from "./safe-delete.ts";
export * from "./state.ts";
export * from "./torrent.ts";
@@ -1,6 +1,14 @@
import { createMockQb, type MockQb, makeMagnet, makeTorrent } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import { createQbClient, DownloaderError, isDownloadComplete, mapQbState, parseMagnet, parseTorrent } from "./index.ts";
import {
assertSafeToDeleteData,
createQbClient,
DownloaderError,
isDownloadComplete,
mapQbState,
parseMagnet,
parseTorrent,
} from "./index.ts";
import { fromDownloaderPath, toDownloaderPath } from "./paths.ts";
let mock: MockQb | null = null;
@@ -272,6 +280,40 @@ describe("QbClient 查询与操作", () => {
expect(mock.get(t.hash)).toBeUndefined();
});
it("deleteFiles=true 必须通过 assertSafeToDeleteData,否则抛 unsafe_delete 且不发请求", async () => {
const { mock, qb } = await setup();
const t = makeTorrent({ name: "Movie.2024.mkv", length: 10 });
await qb.submit({ source: { kind: "file", bytes: t.bytes }, savePath: "/downloads" });
const contentPath = mock.get(t.hash)!.content_path;
const roots = ["/media/movies", "/media/tv"];
// 缺少检查输入(类型上也不允许省略 safety)
// @ts-expect-error safety 缺失
await expect(qb.delete([t.hash], { deleteFiles: true })).rejects.toMatchObject({ kind: "unsafe_delete" });
await expect(
qb.delete([t.hash], {
deleteFiles: true,
safety: { [t.hash]: { contentPath, libraryRoots: roots, importVerified: false } },
}),
).rejects.toMatchObject({ kind: "unsafe_delete" });
// 接管任务 save_path 落在库内
await expect(
qb.delete([t.hash], {
deleteFiles: true,
safety: { [t.hash]: { contentPath: "/media/movies/Movie (2024)", libraryRoots: roots, importVerified: true } },
}),
).rejects.toThrow(/媒体库/);
expect(mock.callsTo("torrents/delete")).toHaveLength(0);
expect(mock.get(t.hash)).toBeDefined();
await qb.delete([t.hash], {
deleteFiles: true,
safety: { [t.hash.toUpperCase()]: { contentPath, libraryRoots: roots, importVerified: true } },
});
expect(mock.callsTo("torrents/delete")[0]!.form.deleteFiles).toBe("true");
expect(mock.get(t.hash)).toBeUndefined();
});
it("5.x 暂停/恢复自动切换到 stop/start,maindata 可读", async () => {
const { mock, qb } = await setup({ version: "5.1.0" });
mock.seed({ hash: "c".repeat(40), name: "C" });
@@ -336,3 +378,45 @@ describe("QbClient 对照 MP 的补充操作", () => {
expect(mock.callsTo("torrents/files")).toHaveLength(3);
});
});
describe("assertSafeToDeleteData", () => {
const roots = ["/media/movies", "/media/tv/"];
const ok =
(contentPath: string, extra: Partial<Parameters<typeof assertSafeToDeleteData>[0]> = {}) =>
() =>
assertSafeToDeleteData({ contentPath, libraryRoots: roots, importVerified: true, ...extra }, "qb-1");
it("库外且已验证入库:通过", () => {
expect(ok("/downloads/Movie.2024.mkv")).not.toThrow();
expect(ok("/downloads/Show.S01/")).not.toThrow();
// 前缀相同但不是子目录
expect(ok("/media/movies-cache/x")).not.toThrow();
});
it("库内、库根本身、库根上级、.. 绕回库内、根目录、相对路径:拒绝", () => {
for (const p of [
"/media/movies/Dune (2021)",
"/media/tv",
"/media",
"/downloads/../media/tv/Show",
"/",
"downloads/x",
"",
]) {
expect(ok(p), p).toThrow(DownloaderError);
}
try {
ok("/media/tv/x")();
} catch (e) {
expect(e).toMatchObject({ kind: "unsafe_delete", ambiguous: false });
}
});
it("未验证入库、库根为空或非绝对路径:拒绝;Windows 路径大小写不敏感", () => {
expect(ok("/downloads/x", { importVerified: false })).toThrow(/尚未验证入库/);
expect(ok("/downloads/x", { libraryRoots: [] })).toThrow(/未提供媒体库根/);
expect(ok("/downloads/x", { libraryRoots: ["media"] })).toThrow(/不是绝对路径/);
expect(ok("D:\\Media\\Movies\\Dune", { libraryRoots: ["d:/Media/Movies"] })).toThrow(/媒体库/);
expect(ok("D:\\Downloads\\Dune", { libraryRoots: ["d:/Media/Movies"] })).not.toThrow();
});
});
@@ -0,0 +1,54 @@
import { posix } from "node:path";
import { DownloaderError } from "./errors.ts";
/**
* 删种连带删数据(deleteFiles=true)前的检查输入(REQ §2.1 原则 5:清理按明确授权)。
* contentPath 与 libraryRoots 必须是同一路径视图(都用下载器视角,或都用本机视角),由调用方经 path_mapping 换算。
*/
export interface SafeDeleteCheck {
/** qB 的 content_path(单文件种子为文件,多文件为根目录) */
contentPath: string;
/** 全部媒体库根(不只是本次入库的目标库);为空视为无法确认,拒绝 */
libraryRoots: readonly string[];
/** 下载内容已完成入库并校验(asset_location 已指向库内副本);只有 true 才允许删缓存 */
importVerified: boolean;
}
/** 规范化为可比较的 POSIX 路径:反斜杠转斜杠、折叠 . / ..、去掉末尾斜杠;Windows 盘符统一小写 */
function normalize(p: string): string | null {
const s = p.trim().replace(/\\/g, "/");
if (!s) return null;
const drive = /^[A-Za-z]:\//.test(s);
if (!drive && !s.startsWith("/")) return null;
let n = posix.normalize(s);
if (n.length > 1 && n.endsWith("/")) n = n.slice(0, -1);
return drive ? n[0]!.toLowerCase() + n.slice(1) : n;
}
const isRootLike = (n: string) => n === "/" || /^[a-z]:\/?$/.test(n);
const within = (child: string, parent: string) =>
child === parent || child.startsWith(parent === "/" ? "/" : `${parent}/`);
/**
* 删数据前的硬检查,不通过抛 `DownloaderError('unsafe_delete')`:
* 1. importVerified 必须为 true;
* 2. contentPath 必须是绝对路径,且不能是根目录;
* 3. contentPath 不能等于或位于任何库根之下,也不能是任何库根的上级目录(删上级会连带删掉整个库)。
* 引用检查(asset_location 是否仍指向该路径)属于业务层,调用方在 importVerified 里体现。
*/
export function assertSafeToDeleteData(check: SafeDeleteCheck, instanceId = ""): void {
const fail = (why: string): never => {
throw new DownloaderError("unsafe_delete", `[qb:${instanceId}] 拒绝删除数据: ${why}`, { instanceId });
};
if (check.importVerified !== true) fail("尚未验证入库");
const content = normalize(check.contentPath);
if (!content) return fail("contentPath 不是绝对路径");
if (isRootLike(content)) fail("contentPath 是根目录");
if (check.libraryRoots.length === 0) fail("未提供媒体库根,无法确认不在库内");
for (const raw of check.libraryRoots) {
const root = normalize(raw);
if (!root) return fail(`媒体库根不是绝对路径: ${raw}`);
if (within(content, root)) fail(`contentPath 位于媒体库 ${root} 内`);
if (within(root, content)) fail(`contentPath 包含媒体库 ${root}`);
}
}