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) throw 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) throw fail(`媒体库根不是绝对路径: ${raw}`); if (within(content, root)) fail(`contentPath 位于媒体库 ${root} 内`); if (within(root, content)) fail(`contentPath 包含媒体库 ${root}`); } }