Files
LNXSDK/leenkx/Sources/leenkx/network/torrent/MetaInfo.hx
2026-10-03 17:40:22 -07:00

712 lines
21 KiB
Haxe

package leenkx.network.torrent;
import haxe.io.Bytes;
typedef TorrentFileEntry = {
var path:String;
var length:Int;
var offset:Int;
@:optional var piecesRoot:Bytes;
}
class MetaInfo {
public var infoHash:Bytes;
public var infoHashHex:String;
public var infoHashV2:Bytes = null;
public var infoHashV2Hex:String = null;
public var metaVersion:Int = 1;
public var name:String;
public var pieceLength:Int;
public var pieces:Bytes;
public var files:Array<TorrentFileEntry>;
public var totalLength:Int = 0;
public var announce:Array<String>;
public var rawInfo:Bytes;
public var isPrivate:Bool = false;
public var comment:String = null;
public var createdBy:String = null;
public var creationDate:Int = 0;
public var urlList:Array<String> = null;
public var source:String = null;
public var fileHashes:Array<Array<Bytes>> = null;
public var storageLength:Int = 0;
public var pieceLayers:Array<{root:Bytes, hashes:Bytes}> = null;
public var numPieces(get, never):Int;
function get_numPieces():Int {
if (pieces != null && pieces.length > 0) {
return Std.int(pieces.length / TorrentInfo.HASH_LENGTH);
}
if (pieceLength > 0 && files != null) {
var end = 0;
for (f in files) {
var e = f.offset + f.length;
if (e > end) end = e;
}
return end > 0
? Std.int(Math.ceil(end / pieceLength)) : 0;
}
return 0;
}
public var protocolVersion(get, never):Int;
function get_protocolVersion():Int {
return metaVersion == 2 ? (pieces != null ? 3 : 2) : 1;
}
public function new() {}
public static function fromTorrentBytes(data:Bytes):MetaInfo {
var dict:Dynamic = Bencode.decode(data);
if (dict == null || Reflect.field(dict, "info") == null) {
throw "MetaInfo: no info dict";
}
var m = new MetaInfo();
m.rawInfo = extractInfoBytes(data);
var info = Reflect.field(dict, "info");
m.parseInfo(info);
if (m.metaVersion == 2) {
m.infoHashV2 = haxe.crypto.Sha256.make(m.rawInfo);
m.infoHashV2Hex = Crypto.toHex(m.infoHashV2);
}
if (m.pieces != null) {
m.infoHash = haxe.crypto.Sha1.make(m.rawInfo);
m.infoHashHex = Crypto.toHex(m.infoHash);
}
m.announce = parseAnnounce(dict);
m.initFileHashes();
m.loadPieceLayers(data);
m.comment = bytesToString(Reflect.field(dict, "comment"));
m.createdBy = bytesToString(Reflect.field(dict, "created by"));
m.creationDate = Std.int(fieldFloat(dict, "creation date"));
var ul = Reflect.field(dict, "url-list");
if (Std.isOfType(ul, Array)) {
m.urlList = [for (u in (ul:Array<Dynamic>))
bytesToString(u)];
} else if (ul != null) {
m.urlList = [bytesToString(ul)];
}
return m;
}
function parseInfo(info:Dynamic):Void {
name = bytesToString(Reflect.field(info, "name"));
if (name == null) name = "";
pieceLength = Std.int(fieldFloat(info, "piece length"));
metaVersion = Std.int(fieldFloat(info, "meta version"));
if (metaVersion == 0) metaVersion = 1;
var piecesField = Reflect.field(info, "pieces");
if (piecesField == null) {
pieces = null;
} else {
pieces = Std.isOfType(piecesField, Bytes) ? piecesField
: Bytes.ofString(Std.string(piecesField));
}
isPrivate = fieldFloat(info, "private") == 1;
source = bytesToString(Reflect.field(info, "source"));
files = [];
totalLength = 0;
var fileTree = Reflect.field(info, "file tree");
if (fileTree != null) {
walkFileTree(fileTree, [name]);
if (pieces != null) {
applyV1Layout(Reflect.field(info, "files"));
}
storageLength = pieces == null
? treeCursor * pieceLength : totalLength;
return;
}
storageLength = 0;
var filesList:Array<Dynamic> = Reflect.field(info, "files");
if (filesList != null) {
for (f in filesList) {
var len = Std.int(fieldFloat(f, "length"));
var pathList:Array<Dynamic> = Reflect.field(f, "path");
var path = name;
if (pathList != null) {
var parts = [for (p in pathList) bytesToString(p)];
path = name + "/" + parts.join("/");
}
files.push({path: sanitizePath(path), length: len,
offset: totalLength});
totalLength += len;
}
} else {
var len = Std.int(fieldFloat(info, "length"));
files.push({path: sanitizePath(name), length: len,
offset: 0});
totalLength = len;
}
storageLength = totalLength;
}
public static function sanitizeComponent(c:String):String {
if (c == null || c == "" || c == "." || c == ".."
|| c.indexOf("/") >= 0 || c.indexOf("\\") >= 0
|| c.indexOf(":") >= 0
|| c.indexOf(String.fromCharCode(0)) >= 0) {
return "_";
}
return c;
}
public static function sanitizePath(p:String):String {
if (p == null) return "_";
var parts = p.split("/");
return [for (c in parts) sanitizeComponent(c)].join("/");
}
public function fileForPiece(index:Int):Int {
for (i in 0...files.length) {
var f = files[i];
var start = Std.int(f.offset / pieceLength);
var np = Std.int(Math.ceil(f.length / pieceLength));
if (index >= start && index < start + np) return i;
}
return -1;
}
public function pieceSizeV2(index:Int):Int {
var fi = fileForPiece(index);
if (fi < 0) return 0;
var f = files[fi];
var start = Std.int(f.offset / pieceLength);
var rem = f.length - (index - start) * pieceLength;
return Std.int(Math.min(pieceLength, rem));
}
public function verifyPieceV2(index:Int, data:Bytes):Bool {
var fi = fileForPiece(index);
if (fi < 0 || fileHashes == null
|| fileHashes.length <= fi || fileHashes[fi] == null) {
return false;
}
var f = files[fi];
var start = Std.int(f.offset / pieceLength);
var expected = fileHashes[fi][index - start];
if (expected == null) return false;
var actual = f.length <= pieceLength
? merkleRoot(data) : pieceRootV2(data);
return memcmp(actual, expected);
}
public function pieceRootV2(data:Bytes):Bytes {
var base = Std.int(Math.log(pieceLength / 16384)
/ Math.log(2));
var pieceLeaves = 1 << base;
var leaves:Array<Bytes> = [];
var pos = 0;
while (pos < data.length) {
var n = Std.int(Math.min(16384, data.length - pos));
leaves.push(haxe.crypto.Sha256.make(data.sub(pos, n)));
pos += n;
}
var zero = leafZero();
while (leaves.length < pieceLeaves) leaves.push(zero);
return merkleReduce(leaves);
}
public function verifyLayer(fi:Int, hashes:Array<Bytes>):Bool {
if (fi < 0 || hashes == null) return false;
var root = files[fi].piecesRoot;
if (root == null) return false;
var base = Std.int(Math.log(pieceLength / 16384)
/ Math.log(2));
var level = hashes.copy();
var pow = 1;
while (pow < level.length) pow <<= 1;
var zero = layerZero(base);
while (level.length < pow) level.push(zero);
return memcmp(merkleReduce(level), root);
}
static var layerZeroCache:Map<Int, Bytes> = [];
public static function layerZero(level:Int):Bytes {
if (level == 0) return leafZero();
var half = layerZero(level - 1);
return hashPair(half, half);
}
public static function hashPair(a:Bytes, b:Bytes):Bytes {
var buf = new haxe.io.BytesBuffer();
buf.addBytes(a, 0, 32);
buf.addBytes(b, 0, 32);
return haxe.crypto.Sha256.make(buf.getBytes());
}
public static function merkleReduce(hashes:Array<Bytes>):Bytes {
var level = hashes;
while (level.length > 1) {
var next:Array<Bytes> = [];
for (i in 0...Std.int(level.length / 2)) {
next.push(hashPair(level[i * 2], level[i * 2 + 1]));
}
level = next;
}
return level.length > 0 ? level[0] : Bytes.alloc(32);
}
static public function memcmp(a:Bytes, b:Bytes):Bool {
if (a == null || b == null || a.length != b.length) return false;
for (i in 0...a.length) if (a.get(i) != b.get(i)) return false;
return true;
}
var treeCursor:Int = 0;
function walkFileTree(node:Dynamic, path:Array<String>):Void {
var aligned = pieces == null;
var keys = Reflect.fields(node);
keys.sort(Reflect.compare);
for (key in keys) {
var child = Reflect.field(node, key);
if (key == "") {
var len = Std.int(fieldFloat(child, "length"));
var p = path.length == 2 && path[0] == name
&& path[1] == name ? name : path.join("/");
var offset = aligned ? treeCursor * pieceLength
: totalLength;
files.push({path: sanitizePath(p), length: len,
offset: offset,
piecesRoot: toBytes(
Reflect.field(child, "pieces root"))});
if (aligned) {
treeCursor += Std.int(
Math.ceil(len / pieceLength));
}
totalLength += len;
} else {
var next = path.copy();
next.push(key);
walkFileTree(child, next);
}
}
}
function applyV1Layout(filesList:Dynamic):Void {
if (!Std.isOfType(filesList, Array)) return;
var byPath = new Map<String, TorrentFileEntry>();
for (f in files) byPath.set(f.path, f);
var ordered:Array<TorrentFileEntry> = [];
var off = 0;
for (fl in (filesList:Array<Dynamic>)) {
var len = Std.int(fieldFloat(fl, "length"));
var pathList:Array<Dynamic> = Reflect.field(fl, "path");
var path = name;
if (pathList != null) {
var parts = [for (p in pathList) bytesToString(p)];
path = name + "/" + parts.join("/");
}
var e = byPath.get(sanitizePath(path));
if (e == null) {
e = {path: sanitizePath(path), length: len,
offset: off};
} else {
e.offset = off;
e.length = len;
}
off += len;
ordered.push(e);
}
files = ordered;
totalLength = off;
}
public function initFileHashes():Void {
if (metaVersion != 2 || files == null
|| pieceLength <= 0) return;
if (fileHashes == null) {
fileHashes = [for (f in files) null];
}
for (fi in 0...files.length) {
var f = files[fi];
if (f.length > 0 && f.length <= pieceLength
&& f.piecesRoot != null) {
fileHashes[fi] = [f.piecesRoot];
}
}
}
public function loadPieceLayers(data:Bytes):Void {
if (metaVersion != 2 || fileHashes == null) return;
var pos = findTopKey(data, "piece layers");
if (pos < 0 || data.get(pos) != 'd'.code) return;
var byRoot = new Map<String, Bytes>();
var p = pos + 1;
while (p < data.length && data.get(p) != 'e'.code) {
var key = rawString(data, p);
if (key == null) return;
p = skipValue(data, p);
var val = rawString(data, p);
if (val == null) return;
p = skipValue(data, p);
byRoot.set(Crypto.toHex(key), val);
}
for (fi in 0...files.length) {
var f = files[fi];
if (f.piecesRoot == null) continue;
var np = Std.int(Math.ceil(f.length / pieceLength));
if (np <= 1) continue;
var raw = byRoot.get(Crypto.toHex(f.piecesRoot));
if (raw == null || raw.length < np * 32) continue;
var hashes = [for (i in 0...np) raw.sub(i * 32, 32)];
if (verifyLayer(fi, hashes)) fileHashes[fi] = hashes;
}
}
static function rawString(data:Bytes, pos:Int):Bytes {
var b = data.get(pos);
if (b < '0'.code || b > '9'.code) return null;
var len = 0;
while (pos < data.length && data.get(pos) != ':'.code) {
len = len * 10 + (data.get(pos) - '0'.code);
pos++;
}
if (pos + 1 + len > data.length) return null;
return data.sub(pos + 1, len);
}
static function findTopKey(data:Bytes, want:String):Int {
if (data.length == 0 || data.get(0) != 'd'.code) return -1;
var pos = 1;
while (pos < data.length && data.get(pos) != 'e'.code) {
var keyEnd = skipValue(data, pos);
var key = data.sub(pos, keyEnd - pos).toString();
var colon = key.indexOf(":");
key = key.substr(colon + 1);
var valEnd = skipValue(data, keyEnd);
if (key == want) return keyEnd;
pos = valEnd;
}
return -1;
}
static function parseAnnounce(dict:Dynamic):Array<String> {
var out:Array<String> = [];
var seen = new Map<String, Bool>();
var list:Array<Dynamic> = Reflect.field(dict, "announce-list");
if (list != null) {
for (tier in list) {
if (Std.isOfType(tier, Array)) {
for (u in (tier:Array<Dynamic>)) {
var s = bytesToString(u);
if (s != null && !seen.exists(s)) {
seen.set(s, true);
out.push(s);
}
}
}
}
}
var single = bytesToString(Reflect.field(dict, "announce"));
if (single != null && !seen.exists(single)) {
out.push(single);
}
return out;
}
public static function fromInfoDict(rawInfo:Bytes, infoHash:Bytes, ?announce:Array<String>):MetaInfo {
var m = new MetaInfo();
m.rawInfo = rawInfo;
m.infoHash = infoHash;
m.infoHashHex = infoHash != null ? Crypto.toHex(infoHash) : null;
var infoDict = Bencode.decode(rawInfo);
m.parseInfo(infoDict);
if (m.metaVersion == 2) {
m.infoHashV2 = haxe.crypto.Sha256.make(rawInfo);
m.infoHashV2Hex = Crypto.toHex(m.infoHashV2);
}
m.announce = announce != null ? announce : [];
m.initFileHashes();
return m;
}
public static function create(name:String, files:Array<{path:String, data:Bytes}>, ?announce:Array<String>, ?opts:Dynamic):MetaInfo {
var m = new MetaInfo();
m.name = name;
m.announce = announce != null ? announce : [];
m.files = [];
var pieceLengthOpt = 0;
if (opts != null) {
m.isPrivate = fieldFloat(opts, "private") == 1
|| Reflect.field(opts, "private") == true;
m.source = Reflect.field(opts, "source");
m.comment = Reflect.field(opts, "comment");
m.createdBy = Reflect.field(opts, "createdBy");
var u:Dynamic = Reflect.field(opts, "urlList");
if (u != null) {
m.urlList = Std.isOfType(u, Array)
? (u:Array<String>) : [Std.string(u)];
}
pieceLengthOpt = Std.int(fieldFloat(opts, "pieceLength"));
}
var mvOpt = opts != null
? Std.int(fieldFloat(opts, "metaVersion")) : 1;
if (mvOpt < 1) mvOpt = 1;
var isV1 = mvOpt != 2;
var isV2 = mvOpt != 1;
m.metaVersion = isV2 ? 2 : 1;
var aligned = mvOpt == 2;
var total = 0;
for (f in files) total += f.data.length;
m.pieceLength = pieceLengthOpt > 0 ? pieceLengthOpt
: TorrentInfo.pieceLength(total);
var cursor = 0;
var content = new haxe.io.BytesBuffer();
for (f in files) {
var entry:TorrentFileEntry = {path: f.path,
length: f.data.length,
offset: aligned ? cursor * m.pieceLength
: m.totalLength};
if (isV2) entry.piecesRoot = merkleRoot(f.data);
m.files.push(entry);
m.totalLength += f.data.length;
cursor += Std.int(Math.ceil(f.data.length
/ m.pieceLength));
content.addBytes(f.data, 0, f.data.length);
}
var all = content.getBytes();
m.storageLength = aligned ? cursor * m.pieceLength
: m.totalLength;
m.pieces = isV1 ? TorrentInfo.pieces(all, m.pieceLength)
: null;
var info:Dynamic = {};
Reflect.setField(info, "name", name);
Reflect.setField(info, "piece length", m.pieceLength);
if (m.isPrivate) Reflect.setField(info, "private", 1);
if (m.source != null) Reflect.setField(info, "source", m.source);
if (isV1) {
Reflect.setField(info, "pieces", m.pieces);
if (files.length == 1) {
Reflect.setField(info, "length", files[0].data.length);
} else {
var fl:Array<Dynamic> = [];
for (f in files) {
var fd:Dynamic = {};
Reflect.setField(fd, "length", f.data.length);
var rel = f.path;
if (rel.indexOf(name + "/") == 0) {
rel = rel.substr(name.length + 1);
}
Reflect.setField(fd, "path", rel.split("/"));
fl.push(fd);
}
Reflect.setField(info, "files", fl);
}
}
if (isV2) {
Reflect.setField(info, "meta version", 2);
Reflect.setField(info, "file tree",
buildFileTree(name, files, m.files));
}
if (isV2) {
m.pieceLayers = [];
for (i in 0...files.length) {
var f = files[i];
var entry = m.files[i];
if (f.data.length <= m.pieceLength
|| entry.piecesRoot == null) continue;
var np = Std.int(Math.ceil(f.data.length
/ m.pieceLength));
var buf = new haxe.io.BytesBuffer();
for (p in 0...np) {
var off = p * m.pieceLength;
var sz = Std.int(Math.min(m.pieceLength,
f.data.length - off));
buf.addBytes(m.pieceRootV2(
f.data.sub(off, sz)), 0, 32);
}
m.pieceLayers.push({root: entry.piecesRoot,
hashes: buf.getBytes()});
}
if (m.pieceLayers.length == 0) m.pieceLayers = null;
}
m.rawInfo = Bencode.encode(info);
if (isV1) {
m.infoHash = haxe.crypto.Sha1.make(m.rawInfo);
m.infoHashHex = Crypto.toHex(m.infoHash);
}
if (isV2) {
m.infoHashV2 = haxe.crypto.Sha256.make(m.rawInfo);
m.infoHashV2Hex = Crypto.toHex(m.infoHashV2);
}
return m;
}
public static function merkleRoot(data:Bytes):Bytes {
if (data.length == 0) return Bytes.alloc(32);
var leaves:Array<Bytes> = [];
var pos = 0;
while (pos < data.length) {
var n = Std.int(Math.min(16384, data.length - pos));
leaves.push(haxe.crypto.Sha256.make(data.sub(pos, n)));
pos += n;
}
var pow = 1;
while (pow < leaves.length) pow <<= 1;
var zero = leafZero();
while (leaves.length < pow) leaves.push(zero);
return merkleReduce(leaves);
}
static var leafZeroCache:Bytes = null;
static function leafZero():Bytes {
if (leafZeroCache == null) leafZeroCache = Bytes.alloc(32);
return leafZeroCache;
}
static function buildFileTree(name:String, files:Array<{path:String, data:Bytes}>, entries:Array<TorrentFileEntry>):Dynamic {
var root:Dynamic = {};
for (i in 0...files.length) {
var rel = files[i].path;
if (rel.indexOf(name + "/") == 0) {
rel = rel.substr(name.length + 1);
}
var parts = rel.split("/");
var node = root;
for (p in 0...parts.length - 1) {
var nxt = Reflect.field(node, parts[p]);
if (nxt == null) {
nxt = {};
Reflect.setField(node, parts[p], nxt);
}
node = nxt;
}
var leaf:Dynamic = {};
Reflect.setField(leaf, "length", files[i].data.length);
Reflect.setField(leaf, "pieces root",
entries[i].piecesRoot);
var leafWrap:Dynamic = {};
Reflect.setField(leafWrap, "", leaf);
Reflect.setField(node, parts[parts.length - 1], leafWrap);
}
return root;
}
public function toTorrentBytes(?extra:Dynamic):Bytes {
var dict:Dynamic = extra != null ? extra : {};
Reflect.setField(dict, "info", Bencode.decode(rawInfo));
if (announce.length > 0) {
Reflect.setField(dict, "announce", announce[0]);
var tiers:Array<Dynamic> = [for (a in announce) [a]];
Reflect.setField(dict, "announce-list", tiers);
}
if (comment != null) Reflect.setField(dict, "comment", comment);
if (createdBy != null) {
Reflect.setField(dict, "created by", createdBy);
}
if (urlList != null && urlList.length > 0) {
Reflect.setField(dict, "url-list",
urlList.length == 1 ? (urlList[0]:Dynamic)
: [for (u in urlList) u]);
}
Reflect.setField(dict, "creation date",
creationDate > 0 ? creationDate
: Std.int(Date.now().getTime() / 1000));
if (pieceLayers != null && pieceLayers.length > 0) {
var pl = new Bencode.RawDict();
var sorted = pieceLayers.copy();
sorted.sort(function(a, b) {
var n = a.root.length < b.root.length
? a.root.length : b.root.length;
for (i in 0...n) {
var d = a.root.get(i) - b.root.get(i);
if (d != 0) return d;
}
return a.root.length - b.root.length;
});
for (e in sorted) {
pl.pairs.push({key: e.root, val: e.hashes});
}
Reflect.setField(dict, "piece layers", pl);
}
return Bencode.encode(dict);
}
public function pieceHash(index:Int):Bytes {
return pieces.sub(index * TorrentInfo.HASH_LENGTH,
TorrentInfo.HASH_LENGTH);
}
public function pieceSize(index:Int):Int {
if (pieces == null) return pieceSizeV2(index); // v2 aligned
if (index == numPieces - 1) {
var rem = totalLength % pieceLength;
return rem == 0 ? pieceLength : rem;
}
return pieceLength;
}
static function fieldFloat(obj:Dynamic, name:String):Float {
var v = Reflect.field(obj, name);
if (v == null) return 0;
if (Std.isOfType(v, Float) || Std.isOfType(v, Int)) return v;
return Std.parseFloat(Std.string(v));
}
static function bytesToString(v:Dynamic):String {
if (v == null) return null;
if (Std.isOfType(v, String)) return v;
if (Std.isOfType(v, Float) || Std.isOfType(v, Int)) {
return Std.string(v);
}
if (Std.isOfType(v, Bytes)) return (v:Bytes).toString();
return Std.string(v);
}
public static function toBytes(v:Dynamic):Bytes {
if (v == null) return null;
if (Std.isOfType(v, Float) || Std.isOfType(v, Int)
|| Std.isOfType(v, String) || Std.isOfType(v, Array)) {
return null;
}
if (Std.isOfType(v, Bytes)) return (v:Bytes);
return null;
}
static function extractInfoBytes(data:Bytes):Bytes {
var pos = 0;
if (data.get(pos) != 'd'.code) throw "MetaInfo: not a dict";
pos++;
while (pos < data.length && data.get(pos) != 'e'.code) {
var keyEnd = skipValue(data, pos);
var key = data.sub(pos, keyEnd - pos).toString();
var colon = key.indexOf(":");
key = key.substr(colon + 1);
var valStart = keyEnd;
var valEnd = skipValue(data, valStart);
if (key == "info") {
return data.sub(valStart, valEnd - valStart);
}
pos = valEnd;
}
throw "MetaInfo: info key not found";
}
static public function skipValue(data:Bytes, pos:Int):Int {
var b = data.get(pos);
if (b == 'i'.code) {
pos++;
while (pos < data.length && data.get(pos) != 'e'.code) pos++;
return pos + 1;
}
if (b == 'l'.code || b == 'd'.code) {
pos++;
while (pos < data.length && data.get(pos) != 'e'.code) {
pos = skipValue(data, pos);
}
return pos + 1;
}
var len = 0;
while (pos < data.length && data.get(pos) != ':'.code) {
len = len * 10 + (data.get(pos) - '0'.code);
pos++;
}
return pos + 1 + len;
}
}