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

@@ -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}`);
}
}