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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user