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:
1 parent
42ecc93fa5
commit
fa9221086b
9 files changed
+371
-9
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
# qbittorrent
|
# 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 文档验证,未连接真实实例。
|
仅对 mock 与官方 API 文档验证,未连接真实实例。
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { DownloaderError, type DownloaderErrorKind } from "./errors.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 { isDownloadComplete, mapQbState, type QbObservedState } from "./state.ts";
|
||||||
import { BencodeError, parseMagnet, parseTorrent } from "./torrent.ts";
|
import { BencodeError, parseMagnet, parseTorrent } from "./torrent.ts";
|
||||||
|
|
||||||
@@ -161,11 +162,19 @@ export interface DownloaderClient {
|
|||||||
deleteTagDefinitions(tags: string[]): Promise<void>;
|
deleteTagDefinitions(tags: string[]): Promise<void>;
|
||||||
/** 从任务移除标签并删除全局定义;hashes 为 null 时只删定义(对照 delete_torrents_tag) */
|
/** 从任务移除标签并删除全局定义;hashes 为 null 时只删定义(对照 delete_torrents_tag) */
|
||||||
deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise<void>;
|
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>;
|
mainData(rid?: number): Promise<QbMainData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除选项:删数据必须附带逐个 hash 的安全检查输入(键为 hash,大小写不敏感) */
|
||||||
|
export type DeleteOptions =
|
||||||
|
| { deleteFiles: false }
|
||||||
|
| { deleteFiles: true; safety: Readonly<Record<string, SafeDeleteCheck>> };
|
||||||
|
|
||||||
export interface TorrentLimits {
|
export interface TorrentLimits {
|
||||||
/** KB/s,0 不限速 */
|
/** KB/s,0 不限速 */
|
||||||
uploadLimitKBps?: number;
|
uploadLimitKBps?: number;
|
||||||
@@ -586,9 +595,19 @@ export class QbClient implements DownloaderClient {
|
|||||||
return this.json<Record<string, unknown>>("transfer/info");
|
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 (typeof opts?.deleteFiles !== "boolean") throw new TypeError("deleteFiles must be explicit");
|
||||||
if (!hashes.length) return;
|
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) });
|
await this.post("torrents/delete", { hashes: hashes.join("|"), deleteFiles: String(opts.deleteFiles) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export type DownloaderErrorKind =
|
|||||||
| "notfound" // 目标 hash 不存在
|
| "notfound" // 目标 hash 不存在
|
||||||
| "rate" // 429 / 限流
|
| "rate" // 429 / 限流
|
||||||
| "rejected" // 下载器明确拒绝(如 torrents/add 返回 Fails.、415 非法种子)
|
| "rejected" // 下载器明确拒绝(如 torrents/add 返回 Fails.、415 非法种子)
|
||||||
| "protocol"; // 响应不符合预期(版本不兼容、5xx 等)
|
| "protocol" // 响应不符合预期(版本不兼容、5xx 等)
|
||||||
|
| "unsafe_delete"; // 删数据前的安全检查未通过(路径落在媒体库内/未验证入库),请求没有发出
|
||||||
|
|
||||||
export class DownloaderError extends Error {
|
export class DownloaderError extends Error {
|
||||||
override readonly name = "DownloaderError";
|
override readonly name = "DownloaderError";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./client.ts";
|
export * from "./client.ts";
|
||||||
export * from "./errors.ts";
|
export * from "./errors.ts";
|
||||||
export * from "./paths.ts";
|
export * from "./paths.ts";
|
||||||
|
export * from "./safe-delete.ts";
|
||||||
export * from "./state.ts";
|
export * from "./state.ts";
|
||||||
export * from "./torrent.ts";
|
export * from "./torrent.ts";
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { createMockQb, type MockQb, makeMagnet, makeTorrent } from "@umt/testkit";
|
import { createMockQb, type MockQb, makeMagnet, makeTorrent } from "@umt/testkit";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
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";
|
import { fromDownloaderPath, toDownloaderPath } from "./paths.ts";
|
||||||
|
|
||||||
let mock: MockQb | null = null;
|
let mock: MockQb | null = null;
|
||||||
@@ -272,6 +280,40 @@ describe("QbClient 查询与操作", () => {
|
|||||||
expect(mock.get(t.hash)).toBeUndefined();
|
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 () => {
|
it("5.x 暂停/恢复自动切换到 stop/start,maindata 可读", async () => {
|
||||||
const { mock, qb } = await setup({ version: "5.1.0" });
|
const { mock, qb } = await setup({ version: "5.1.0" });
|
||||||
mock.seed({ hash: "c".repeat(40), name: "C" });
|
mock.seed({ hash: "c".repeat(40), name: "C" });
|
||||||
@@ -336,3 +378,45 @@ describe("QbClient 对照 MP 的补充操作", () => {
|
|||||||
expect(mock.callsTo("torrents/files")).toHaveLength(3);
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
# @umt/testkit
|
# @umt/testkit
|
||||||
|
|
||||||
测试夹具与 mock 服务:mock qB WebUI、mock NexusPHP 站点、mock M-Team API、mock RSS、mock TMDB 与临时目录夹具。只供测试依赖,不进入生产构建。
|
测试夹具与 mock 服务:mock qB WebUI、mock NexusPHP 站点、mock M-Team API、mock RSS、mock TMDB 与临时目录夹具。只供测试依赖,不进入生产构建。
|
||||||
|
|
||||||
|
## mock qB 落盘
|
||||||
|
|
||||||
|
`createMockQb()` 默认只改内存状态。e2e 需要"下载完成 → Copy 入库"时:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const qb = await createMockQb();
|
||||||
|
// files 的 path 相对任务 save_path(多文件种子含根目录);写随机内容,大小与清单一致,返回绝对路径
|
||||||
|
const paths = await qb.complete(hash, { files: [{ path: "Show.S01/Show.S01E01.mkv", size: 4 * 1024 * 1024 }] });
|
||||||
|
```
|
||||||
|
|
||||||
|
- `createMockQb({ materializeFiles: true })` 让所有 `complete()` 按现有文件清单落盘;单次总量上限 `maxMaterializeBytes`(默认 64 MiB)。
|
||||||
|
- `torrents/delete` 带 `deleteFiles=true` 时同步删除落盘文件和因此变空的目录(不越过 save_path)。
|
||||||
|
- save_path 请用临时目录(`mkdtemp`),不要用 `/downloads` 这类真实路径。
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { rmdirSync, rmSync } from "node:fs";
|
||||||
|
import { mkdir, open } from "node:fs/promises";
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
||||||
import { inspectTorrent } from "./bencode.ts";
|
import { inspectTorrent } from "./bencode.ts";
|
||||||
import { parseCookies, parseMultipart, readBody } from "./http-util.ts";
|
import { parseCookies, parseMultipart, readBody } from "./http-util.ts";
|
||||||
|
|
||||||
@@ -77,9 +80,21 @@ export interface MockQbOptions {
|
|||||||
preferences?: Record<string, unknown>;
|
preferences?: Record<string, unknown>;
|
||||||
/** 磁力无元数据时的默认大小 */
|
/** 磁力无元数据时的默认大小 */
|
||||||
magnetDefaultSize?: number;
|
magnetDefaultSize?: number;
|
||||||
|
/**
|
||||||
|
* complete() 时是否默认在 save_path 下真的写出文件(随机内容,大小与文件清单一致),供"下载完成 → Copy 入库" e2e 使用。
|
||||||
|
* 默认 false:现有用例的 save_path 多是 /downloads 之类的假路径,不能落盘。
|
||||||
|
*/
|
||||||
|
materializeFiles?: boolean;
|
||||||
|
/** 单次 complete() 最多写出的总字节数,默认 64 MiB;超出抛错(请用 files 配小文件) */
|
||||||
|
maxMaterializeBytes?: number;
|
||||||
port?: number;
|
port?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MockQbCompleteOptions {
|
||||||
|
writeFiles?: boolean;
|
||||||
|
files?: { path: string; size: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MockQb {
|
export interface MockQb {
|
||||||
url: string;
|
url: string;
|
||||||
calls: MockQbCall[];
|
calls: MockQbCall[];
|
||||||
@@ -89,7 +104,11 @@ export interface MockQb {
|
|||||||
tagDefinitions(): string[];
|
tagDefinitions(): string[];
|
||||||
seed(t: Partial<MockQbTorrent> & { hash: string; name: string }): MockQbTorrent;
|
seed(t: Partial<MockQbTorrent> & { hash: string; name: string }): MockQbTorrent;
|
||||||
setProgress(hash: string, progress: number): void;
|
setProgress(hash: string, progress: number): void;
|
||||||
complete(hash: string): void;
|
/**
|
||||||
|
* 置为完成。writeFiles(或给了 files、或 materializeFiles=true)时在 save_path 下写出随机内容文件,
|
||||||
|
* 返回写出的绝对路径;files 同时替换任务的文件清单(path 相对 save_path,多文件种子含根目录)。
|
||||||
|
*/
|
||||||
|
complete(hash: string, opts?: MockQbCompleteOptions): Promise<string[]>;
|
||||||
setState(hash: string, state: string): void;
|
setState(hash: string, state: string): void;
|
||||||
setError(hash: string): void;
|
setError(hash: string): void;
|
||||||
setMissingFiles(hash: string): void;
|
setMissingFiles(hash: string): void;
|
||||||
@@ -172,6 +191,77 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** hash → 已落盘的文件(绝对路径);deleteFiles=true 时同步删除 */
|
||||||
|
const materialized = new Map<string, string[]>();
|
||||||
|
|
||||||
|
function safeJoin(root: string, rel: string): string {
|
||||||
|
const n = normalize(rel);
|
||||||
|
if (!rel || isAbsolute(rel) || n === ".." || n.startsWith(`..${sep}`)) {
|
||||||
|
throw new Error(`mock qb: 非法文件路径 ${rel}`);
|
||||||
|
}
|
||||||
|
return join(root, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFiles(t: MockQbTorrent, files: { path: string; size: number }[]) {
|
||||||
|
t.files = files.map((f) => ({ name: f.path, size: f.size, progress: t.progress, priority: 1 }));
|
||||||
|
t.size = t.total_size = files.reduce((a, f) => a + f.size, 0);
|
||||||
|
const heads = new Set(files.map((f) => f.path.split("/")[0]));
|
||||||
|
const single = files.length === 1 && !files[0]!.path.includes("/");
|
||||||
|
const root = t.save_path.replace(/\/$/, "");
|
||||||
|
t.content_path = single
|
||||||
|
? `${root}/${files[0]!.path}`
|
||||||
|
: heads.size === 1 && files.every((f) => f.path.includes("/"))
|
||||||
|
? `${root}/${[...heads][0]}`
|
||||||
|
: root;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function materialize(t: MockQbTorrent): Promise<string[]> {
|
||||||
|
const total = t.files.reduce((a, f) => a + (f.priority === 0 ? 0 : f.size), 0);
|
||||||
|
const limit = opts.maxMaterializeBytes ?? 64 * 1024 * 1024;
|
||||||
|
if (total > limit) {
|
||||||
|
throw new Error(`mock qb: 要写出 ${total} 字节,超过上限 ${limit};用 complete(hash, { files }) 配小文件`);
|
||||||
|
}
|
||||||
|
const written: string[] = [];
|
||||||
|
for (const f of t.files) {
|
||||||
|
if (f.priority === 0) continue;
|
||||||
|
const abs = safeJoin(t.save_path, f.name);
|
||||||
|
await mkdir(dirname(abs), { recursive: true });
|
||||||
|
const fh = await open(abs, "w");
|
||||||
|
try {
|
||||||
|
for (let left = f.size; left > 0; ) {
|
||||||
|
const n = Math.min(left, 1024 * 1024);
|
||||||
|
await fh.write(randomBytes(n));
|
||||||
|
left -= n;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fh.close();
|
||||||
|
}
|
||||||
|
written.push(abs);
|
||||||
|
}
|
||||||
|
materialized.set(t.hash, [...(materialized.get(t.hash) ?? []), ...written]);
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除落盘文件,并自下而上清掉因此变空的目录(不越过 save_path) */
|
||||||
|
function removeMaterialized(t: MockQbTorrent) {
|
||||||
|
const files = materialized.get(t.hash) ?? [];
|
||||||
|
materialized.delete(t.hash);
|
||||||
|
const dirs = new Set<string>();
|
||||||
|
for (const f of files) {
|
||||||
|
rmSync(f, { force: true });
|
||||||
|
for (let d = dirname(f); relative(t.save_path, d) && !relative(t.save_path, d).startsWith(".."); d = dirname(d)) {
|
||||||
|
dirs.add(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const d of [...dirs].sort((a, b) => b.length - a.length)) {
|
||||||
|
try {
|
||||||
|
rmdirSync(d);
|
||||||
|
} catch {
|
||||||
|
// 非空或已不存在:保留
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toInfo(t: MockQbTorrent) {
|
function toInfo(t: MockQbTorrent) {
|
||||||
const { files: _f, hiddenPolls: _h, tags, ...rest } = t;
|
const { files: _f, hiddenPolls: _h, tags, ...rest } = t;
|
||||||
return { ...rest, tags: tags.join(", ") };
|
return { ...rest, tags: tags.join(", ") };
|
||||||
@@ -401,7 +491,10 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
|
|||||||
"torrents/tags": () => ({ body: JSON.stringify([...tagDefs]), json: true }),
|
"torrents/tags": () => ({ body: JSON.stringify([...tagDefs]), json: true }),
|
||||||
"torrents/delete": (_q, form) => {
|
"torrents/delete": (_q, form) => {
|
||||||
if (form.deleteFiles !== "true" && form.deleteFiles !== "false") return { status: 400, body: "deleteFiles" };
|
if (form.deleteFiles !== "true" && form.deleteFiles !== "false") return { status: 400, body: "deleteFiles" };
|
||||||
for (const t of select(form.hashes)) torrents.delete(t.hash);
|
for (const t of select(form.hashes)) {
|
||||||
|
if (form.deleteFiles === "true") removeMaterialized(t);
|
||||||
|
torrents.delete(t.hash);
|
||||||
|
}
|
||||||
return { body: "" };
|
return { body: "" };
|
||||||
},
|
},
|
||||||
"sync/maindata": () => {
|
"sync/maindata": () => {
|
||||||
@@ -508,7 +601,13 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
|
|||||||
return m;
|
return m;
|
||||||
},
|
},
|
||||||
setProgress: (h, p) => setProgress(need(h), p),
|
setProgress: (h, p) => setProgress(need(h), p),
|
||||||
complete: (h) => setProgress(need(h), 1),
|
complete: async (h, o = {}) => {
|
||||||
|
const t = need(h);
|
||||||
|
if (o.files) applyFiles(t, o.files);
|
||||||
|
setProgress(t, 1);
|
||||||
|
const write = o.writeFiles ?? (o.files !== undefined || opts.materializeFiles === true);
|
||||||
|
return write ? materialize(t) : [];
|
||||||
|
},
|
||||||
setState: (h, s) => {
|
setState: (h, s) => {
|
||||||
need(h).state = s;
|
need(h).state = s;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createMockQb, type MockQb } from "../src/index.ts";
|
||||||
|
|
||||||
|
let mock: MockQb | null = null;
|
||||||
|
let dir: string | null = null;
|
||||||
|
afterEach(async () => {
|
||||||
|
await mock?.close();
|
||||||
|
mock = null;
|
||||||
|
if (dir) await rm(dir, { recursive: true, force: true });
|
||||||
|
dir = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function login(m: MockQb): Promise<string> {
|
||||||
|
const res = await fetch(`${m.url}/api/v2/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({ username: "admin", password: "adminadmin" }),
|
||||||
|
});
|
||||||
|
return /SID=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")![1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(m: MockQb, hash: string, deleteFiles: boolean) {
|
||||||
|
const sid = await login(m);
|
||||||
|
const res = await fetch(`${m.url}/api/v2/torrents/delete`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { cookie: `SID=${sid}` },
|
||||||
|
body: new URLSearchParams({ hashes: hash, deleteFiles: String(deleteFiles) }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const H = "c".repeat(40);
|
||||||
|
|
||||||
|
describe("mock qB 落盘(B8)", () => {
|
||||||
|
it("complete({ files }) 在 save_path 下写出随机内容文件,并更新文件清单与 content_path", async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "umt-mockqb-"));
|
||||||
|
mock = await createMockQb();
|
||||||
|
mock.seed({ hash: H, name: "Show.S01", save_path: dir });
|
||||||
|
const written = await mock.complete(H, {
|
||||||
|
files: [
|
||||||
|
{ path: "Show.S01/Show.S01E01.mkv", size: 3 * 1024 * 1024 + 7 },
|
||||||
|
{ path: "Show.S01/Subs/Show.S01E01.chs.srt", size: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(written).toEqual([join(dir, "Show.S01/Show.S01E01.mkv"), join(dir, "Show.S01/Subs/Show.S01E01.chs.srt")]);
|
||||||
|
expect(statSync(written[0]!).size).toBe(3 * 1024 * 1024 + 7);
|
||||||
|
expect(statSync(written[1]!).size).toBe(100);
|
||||||
|
// 随机内容:不是全零
|
||||||
|
expect((await readFile(written[1]!)).some((b) => b !== 0)).toBe(true);
|
||||||
|
const t = mock.get(H)!;
|
||||||
|
expect(t).toMatchObject({ progress: 1, size: 3 * 1024 * 1024 + 107, content_path: `${dir}/Show.S01` });
|
||||||
|
expect(t.files.map((f) => f.name)).toEqual(["Show.S01/Show.S01E01.mkv", "Show.S01/Subs/Show.S01E01.chs.srt"]);
|
||||||
|
|
||||||
|
// deleteFiles=true 同步删除落盘文件和因此变空的目录,save_path 本身保留
|
||||||
|
await del(mock, H, true);
|
||||||
|
expect(written.some((p) => existsSync(p))).toBe(false);
|
||||||
|
expect(readdirSync(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteFiles=false 只删任务不删文件;默认不落盘,materializeFiles 打开后按现有清单写", async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "umt-mockqb-"));
|
||||||
|
mock = await createMockQb({ materializeFiles: true });
|
||||||
|
mock.seed({ hash: H, name: "Movie.2024.mkv", save_path: dir, size: 2048 });
|
||||||
|
const written = await mock.complete(H);
|
||||||
|
expect(written).toEqual([join(dir, "Movie.2024.mkv")]);
|
||||||
|
await del(mock, H, false);
|
||||||
|
expect(existsSync(written[0]!)).toBe(true);
|
||||||
|
|
||||||
|
const plain = await createMockQb();
|
||||||
|
try {
|
||||||
|
plain.seed({ hash: H, name: "x.mkv", save_path: "/downloads" });
|
||||||
|
expect(await plain.complete(H)).toEqual([]);
|
||||||
|
expect(plain.get(H)!.progress).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await plain.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("超出落盘上限、路径越界时抛错", async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "umt-mockqb-"));
|
||||||
|
mock = await createMockQb({ maxMaterializeBytes: 1024 });
|
||||||
|
mock.seed({ hash: H, name: "big.mkv", save_path: dir, size: 4096 });
|
||||||
|
await expect(mock.complete(H, { writeFiles: true })).rejects.toThrow(/超过上限/);
|
||||||
|
await expect(mock.complete(H, { files: [{ path: "../escape.mkv", size: 1 }] })).rejects.toThrow(/非法文件路径/);
|
||||||
|
expect(readdirSync(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user