55 lines
2.7 KiB
TypeScript
55 lines
2.7 KiB
TypeScript
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}`);
|
|
}
|
|
}
|