import { createHash } from "node:crypto"; /** 测试用 bencode 编解码与种子构造器(独立于 adapters 实现,用于交叉校验 infohash)。 */ export type BVal = number | string | Uint8Array | BVal[] | { [k: string]: BVal }; export function bencode(v: BVal): Buffer { if (typeof v === "number") return Buffer.from(`i${Math.trunc(v)}e`); if (typeof v === "string") { const b = Buffer.from(v, "utf8"); return Buffer.concat([Buffer.from(`${b.length}:`), b]); } if (v instanceof Uint8Array) return Buffer.concat([Buffer.from(`${v.length}:`), v]); if (Array.isArray(v)) return Buffer.concat([Buffer.from("l"), ...v.map(bencode), Buffer.from("e")]); const keys = Object.keys(v).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b))); return Buffer.concat([Buffer.from("d"), ...keys.flatMap((k) => [bencode(k), bencode(v[k]!)]), Buffer.from("e")]); } type Dec = number | Buffer | Dec[] | { [k: string]: Dec }; export function bdecodeLoose(buf: Buffer): Dec { let pos = 0; const readUntil = (ch: string) => { const end = buf.indexOf(ch, pos); if (end < 0) throw new Error("bad bencode"); const s = buf.subarray(pos, end).toString(); pos = end + 1; return s; }; const val = (): Dec => { const c = String.fromCharCode(buf[pos]!); if (c === "i") { pos++; return Number(readUntil("e")); } if (c === "l") { pos++; const out: Dec[] = []; while (buf[pos] !== 0x65) out.push(val()); pos++; return out; } if (c === "d") { pos++; const out: { [k: string]: Dec } = {}; while (buf[pos] !== 0x65) { const k = (val() as Buffer).toString(); out[k] = val(); } pos++; return out; } const n = Number(readUntil(":")); const b = buf.subarray(pos, pos + n); pos += n; return b; }; return val(); } export interface MakeTorrentInput { name: string; /** 省略则为单文件种子,长度用 length */ files?: { path: string; length: number }[]; length?: number; pieceLength?: number; private?: boolean; announce?: string; /** 改变伪 pieces 内容,用于构造同名同大小但 infohash 不同的种子(realPieces 时也参与确定性内容生成) */ pieceSeed?: number; /** * 生成真实 piece hash(可被真 qB 校验通过):true 时按 pieceSeed + 文件路径生成确定性内容, * 也可传函数自定义每个文件的内容(长度须等于 length)。内容随返回值 contents 给出,调用方写盘后 qB 重新校验即完成。 * 默认 false:pieces 为伪值,不可真正下载(旧行为)。 */ realPieces?: boolean | ((path: string, length: number) => Uint8Array); } export interface MadeTorrent { bytes: Buffer; hash: string; name: string; totalSize: number; files: { path: string; length: number }[]; /** realPieces 时:每个文件的内容(path 同 files[].path,含种子根目录) */ contents?: { path: string; bytes: Uint8Array }[]; } /** 确定性伪随机内容(xorshift32),同 seed + path 得到同样字节 */ export function deterministicContent(seedKey: string, length: number): Uint8Array { let x = 2166136261; for (let i = 0; i < seedKey.length; i++) x = Math.imul(x ^ seedKey.charCodeAt(i), 16777619) >>> 0; if (x === 0) x = 1; const out = new Uint8Array(length); for (let i = 0; i < length; i++) { x ^= x << 13; x >>>= 0; x ^= x >>> 17; x ^= x << 5; x >>>= 0; out[i] = x & 0xff; } return out; } /** 构造一个合法的 v1 .torrent;realPieces 时 pieces 为按内容计算的真实 SHA-1,否则为伪值。 */ export function makeTorrent(input: MakeTorrentInput): MadeTorrent { const pieceLength = input.pieceLength ?? 4 * 1024 * 1024; const info: { [k: string]: BVal } = { name: input.name, "piece length": pieceLength }; let files: { path: string; length: number }[]; if (input.files) { info.files = input.files.map((f) => ({ length: f.length, path: f.path.split("/") })); files = input.files.map((f) => ({ path: `${input.name}/${f.path}`, length: f.length })); } else { info.length = input.length ?? 1024 * 1024 * 1024; files = [{ path: input.name, length: info.length as number }]; } const total = files.reduce((a, f) => a + f.length, 0); let contents: MadeTorrent["contents"]; if (input.realPieces) { const gen = typeof input.realPieces === "function" ? input.realPieces : (path: string, length: number) => deterministicContent(`${input.pieceSeed ?? 7}:${path}`, length); contents = files.map((f) => { const bytes = gen(f.path, f.length); if (bytes.length !== f.length) throw new Error(`realPieces: ${f.path} 内容长度 ${bytes.length} ≠ ${f.length}`); return { path: f.path, bytes }; }); // v1:所有文件按顺序拼成一个字节流,按 piece length 切片逐片 SHA-1 const hashes: Buffer[] = []; let h = createHash("sha1"); let filled = 0; for (const c of contents) { let off = 0; while (off < c.bytes.length) { const n = Math.min(pieceLength - filled, c.bytes.length - off); h.update(c.bytes.subarray(off, off + n)); off += n; filled += n; if (filled === pieceLength) { hashes.push(h.digest()); h = createHash("sha1"); filled = 0; } } } if (filled > 0 || hashes.length === 0) hashes.push(h.digest()); info.pieces = Buffer.concat(hashes); } else { const pieces = Math.max(1, Math.ceil(total / pieceLength)); info.pieces = Buffer.alloc(20 * Math.min(pieces, 4), input.pieceSeed ?? 7); } if (input.private) info.private = 1; const root: { [k: string]: BVal } = { announce: input.announce ?? "https://tracker.example.invalid/announce", info }; const bytes = bencode(root); const hash = createHash("sha1").update(bencode(info)).digest("hex"); return { bytes, hash, name: input.name, totalSize: total, files, ...(contents ? { contents } : {}) }; } export function makeMagnet(hash: string, name?: string): string { return `magnet:?xt=urn:btih:${hash}${name ? `&dn=${encodeURIComponent(name)}` : ""}`; } /** 从 .torrent 字节解析(mock 服务器使用):hash = sha1(bencode(info)) */ export function inspectTorrent(bytes: Buffer): MadeTorrent { const root = bdecodeLoose(bytes) as { [k: string]: Dec }; const info = root.info as { [k: string]: Dec }; if (!info) throw new Error("no info"); const reenc = (d: Dec): BVal => Buffer.isBuffer(d) ? d : Array.isArray(d) ? d.map(reenc) : typeof d === "number" ? d : Object.fromEntries(Object.entries(d).map(([k, v]) => [k, reenc(v)])); const hash = createHash("sha1") .update(bencode(reenc(info))) .digest("hex"); const name = (info.name as Buffer).toString(); const files = Array.isArray(info.files) ? (info.files as { [k: string]: Dec }[]).map((f) => ({ path: [name, ...(f.path as Buffer[]).map((p) => p.toString())].join("/"), length: f.length as number, })) : [{ path: name, length: info.length as number }]; return { bytes, hash, name, totalSize: files.reduce((a, f) => a + f.length, 0), files }; }