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
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { DownloaderError, type DownloaderErrorKind } from "./errors.ts";
|
||||
import { assertSafeToDeleteData, type SafeDeleteCheck } from "./safe-delete.ts";
|
||||
import { isDownloadComplete, mapQbState, type QbObservedState } from "./state.ts";
|
||||
import { BencodeError, parseMagnet, parseTorrent } from "./torrent.ts";
|
||||
|
||||
@@ -161,11 +162,19 @@ export interface DownloaderClient {
|
||||
deleteTagDefinitions(tags: string[]): Promise<void>;
|
||||
/** 从任务移除标签并删除全局定义;hashes 为 null 时只删定义(对照 delete_torrents_tag) */
|
||||
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>;
|
||||
}
|
||||
|
||||
/** 删除选项:删数据必须附带逐个 hash 的安全检查输入(键为 hash,大小写不敏感) */
|
||||
export type DeleteOptions =
|
||||
| { deleteFiles: false }
|
||||
| { deleteFiles: true; safety: Readonly<Record<string, SafeDeleteCheck>> };
|
||||
|
||||
export interface TorrentLimits {
|
||||
/** KB/s,0 不限速 */
|
||||
uploadLimitKBps?: number;
|
||||
@@ -586,9 +595,19 @@ export class QbClient implements DownloaderClient {
|
||||
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 (!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) });
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user