import { bytesToBase64Url, sha256Bytes } from './crypto-utils.js'; export const TORRENT_V2_BLOCK_BYTES = 16 * 1024; export const TORRENT_V2_PIECE_BYTES = 1024 * 1024; const ZERO_HASH = new Uint8Array(32); const encoder = new TextEncoder(); function concatBytes(parts = []) { const arrays = parts.map((part) => part instanceof Uint8Array ? part : new Uint8Array(part || 0)); const total = arrays.reduce((sum, part) => sum + part.byteLength, 0); const out = new Uint8Array(total); let offset = 0; for (const part of arrays) { out.set(part, offset); offset += part.byteLength; } return out; } async function hashPair(left, right) { return sha256Bytes(concatBytes([left, right])); } function nextPowerOfTwo(value) { let n = 1; while (n < value) n *= 2; return n; } async function reduceMerkleLevel(nodes) { if (nodes.length <= 1) return nodes; const next = []; for (let i = 0; i < nodes.length; i += 2) { next.push(hashPair(nodes[i], nodes[i + 1])); } return Promise.all(next); } /** * BEP 52 pieces root / piece-layer hash for one piece. * Leaves are SHA-256 hashes of 16 KiB blocks. Padding leaves are 32 zero bytes, * exactly as required by BitTorrent v2. */ export async function computeTorrentV2PieceRoot(pieceBytes, { padToPieceLength = false } = {}) { const bytes = pieceBytes instanceof Uint8Array ? pieceBytes : new Uint8Array(pieceBytes || 0); if (!bytes.byteLength) return null; const blockCount = Math.ceil(bytes.byteLength / TORRENT_V2_BLOCK_BYTES); const leafPromises = []; for (let offset = 0; offset < bytes.byteLength; offset += TORRENT_V2_BLOCK_BYTES) { leafPromises.push(sha256Bytes(bytes.subarray(offset, Math.min(bytes.byteLength, offset + TORRENT_V2_BLOCK_BYTES)))); } let level = await Promise.all(leafPromises); const targetLeaves = padToPieceLength ? (TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES) : nextPowerOfTwo(Math.max(1, blockCount)); while (level.length < targetLeaves) level.push(ZERO_HASH.slice()); while (level.length > 1) level = await reduceMerkleLevel(level); return level[0]; } let zeroPieceRootPromise = null; export function getTorrentV2ZeroPieceRoot() { if (!zeroPieceRootPromise) { zeroPieceRootPromise = (async () => { let level = Array.from( { length: TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES }, () => ZERO_HASH.slice(), ); while (level.length > 1) level = await reduceMerkleLevel(level); return level[0]; })(); } return zeroPieceRootPromise; } /** * Streaming Merkle accumulator for the BEP 52 piece layer. It keeps only O(log n) * hashes in memory and pads the right side with the standard zero-piece subtree. */ export class TorrentV2PieceAccumulator { constructor() { this.stack = []; this.count = 0; } async #addSubtree(hash, level) { let current = hash; let currentLevel = level; while (this.stack[currentLevel]) { current = await hashPair(this.stack[currentLevel], current); this.stack[currentLevel] = null; currentLevel += 1; } this.stack[currentLevel] = current; } async addPieceRoot(hash) { if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) { throw new Error('Некорректный torrent-v2 piece hash'); } await this.#addSubtree(hash, 0); this.count += 1; } async finalize() { if (this.count <= 0) return null; let target = 1; while (target < this.count) target *= 2; let remaining = target - this.count; let count = this.count; const zeroRoots = [await getTorrentV2ZeroPieceRoot()]; const ensureZeroLevel = async (level) => { while (zeroRoots.length <= level) { const previous = zeroRoots[zeroRoots.length - 1]; zeroRoots.push(await hashPair(previous, previous)); } return zeroRoots[level]; }; while (remaining > 0) { let maxByRemaining = Math.floor(Math.log2(remaining)); let alignmentLevel = 0; let aligned = count; while (aligned > 0 && aligned % 2 === 0) { alignmentLevel += 1; aligned /= 2; } const level = Math.min(maxByRemaining, alignmentLevel); const blockLeaves = 2 ** level; await this.#addSubtree(await ensureZeroLevel(level), level); count += blockLeaves; remaining -= blockLeaves; } const root = this.stack.findLast?.((value) => value) || [...this.stack].reverse().find((value) => value) || null; return root ? root.slice() : null; } } function encodeBString(bytes) { const data = bytes instanceof Uint8Array ? bytes : encoder.encode(String(bytes ?? '')); return concatBytes([encoder.encode(`${data.byteLength}:`), data]); } function compareByteArrays(left, right) { const limit = Math.min(left.byteLength, right.byteLength); for (let i = 0; i < limit; i += 1) { if (left[i] !== right[i]) return left[i] - right[i]; } return left.byteLength - right.byteLength; } function encodeBValue(value) { if (value instanceof Uint8Array) return encodeBString(value); if (typeof value === 'string') return encodeBString(encoder.encode(value)); if (typeof value === 'number' || typeof value === 'bigint') { const integer = typeof value === 'bigint' ? value : BigInt(Math.trunc(value)); return encoder.encode(`i${integer.toString()}e`); } if (Array.isArray(value)) { return concatBytes([encoder.encode('l'), ...value.map(encodeBValue), encoder.encode('e')]); } if (value && typeof value === 'object') { const entries = Object.entries(value).map(([key, item]) => ({ keyBytes: encoder.encode(key), value: item, })).sort((a, b) => compareByteArrays(a.keyBytes, b.keyBytes)); const parts = [encoder.encode('d')]; for (const entry of entries) { parts.push(encodeBString(entry.keyBytes), encodeBValue(entry.value)); } parts.push(encoder.encode('e')); return concatBytes(parts); } throw new Error('Неподдерживаемое значение bencode'); } export function buildTorrentV2InfoBytes({ name, size, piecesRoot, pieceLength = TORRENT_V2_PIECE_BYTES } = {}) { const cleanName = String(name || 'file'); const fileData = { length: Math.max(0, Math.trunc(Number(size || 0))) }; if (fileData.length > 0) { if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) { throw new Error('Для непустого файла нужен 32-байтный pieces root'); } fileData['pieces root'] = piecesRoot; } return encodeBValue({ 'file tree': { [cleanName]: { '': fileData, }, }, 'meta version': 2, name: cleanName, 'piece length': Math.trunc(pieceLength), }); } export async function computeTorrentV2InfoHash({ name, size, piecesRoot } = {}) { const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot }); const hash = await sha256Bytes(infoBytes); return { infoBytes, infoHash: hash, infoHashB64Url: bytesToBase64Url(hash), }; } /** Builds a tracker-less BitTorrent v2 metainfo file. */ export function buildTorrentV2MetainfoBytes({ name, size, piecesRoot, pieceLayer = [] } = {}) { const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot }); const outer = [encoder.encode('d'), encodeBString(encoder.encode('info')), infoBytes]; if (Number(size || 0) > TORRENT_V2_PIECE_BYTES) { if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) { throw new Error('Некорректный pieces root'); } const hashes = (Array.isArray(pieceLayer) ? pieceLayer : []).map((hash) => { if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) throw new Error('Некорректный piece layer'); return hash; }); const layerBytes = concatBytes(hashes); outer.push( encodeBString(encoder.encode('piece layers')), encoder.encode('d'), encodeBString(piecesRoot), encodeBString(layerBytes), encoder.encode('e'), ); } outer.push(encoder.encode('e')); return concatBytes(outer); }