Files
SHiNE-server/shine-UI/js/services/torrent-v2-service.js
T

235 lines
7.9 KiB
JavaScript

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);
}