///|
pub(all) struct TorrentInfo {
name : String
mode : TorrentMode
piece_length : Int32
files : Array[SourceFile]
single_file : Bool
private_torrent : Bool
trackers : Array[Array[String]]
web_seeds : Array[String]
comment : String
info_hash_v1 : String?
info_hash_v2 : String?
}
///|
fn bn(value : BValue) -> BNode {
{ value, start: 0, end: 0, }
}
///|
fn bn_text(text : String) -> BNode {
bn(Blob(@utf8.encode(text)))
}
///|
fn bn_int(n : Int64) -> BNode {
bn(Integer(n))
}
///|
fn bn_dict(pairs : Array[(String, BNode)]) -> BNode {
bn(Dictionary(pairs.map(fn(p) { (@utf8.encode(p.0), p.1) })))
}
///|
fn dict_pairs(node : BNode) -> Array[(Bytes, BNode)] raise CrateError {
match node.value {
Dictionary(pairs) => pairs
_ => {
reject("metainfo", "expected dictionary")
[]
}
}
}
///|
fn lookup(node : BNode, name : String) -> BNode? raise CrateError {
let key = @utf8.encode(name)
for (k, value) in dict_pairs(node) {
if k == key {
return Some(value)
}
}
None
}
///|
fn require_key(node : BNode, name : String) -> BNode raise CrateError {
match lookup(node, name) {
Some(value) => value
None => {
reject("metainfo", "missing field: " + name)
bn_int(0L)
}
}
}
///|
fn as_blob(node : BNode) -> Bytes raise CrateError {
match node.value {
Blob(data) => data
_ => {
reject("metainfo", "expected byte string")
b""
}
}
}
///|
fn as_text(node : BNode) -> String raise CrateError {
@utf8.decode(as_blob(node)) catch {
_ => {
reject("utf8", "invalid UTF-8 metadata text")
""
}
}
}
///|
fn as_int(node : BNode) -> Int64 raise CrateError {
match node.value {
Integer(n) => n
_ => {
reject("metainfo", "expected integer")
0L
}
}
}
///|
fn as_list(node : BNode) -> Array[BNode] raise CrateError {
match node.value {
Sequence(items) => items
_ => {
reject("metainfo", "expected list")
[]
}
}
}
///|
fn read_trackers(top : BNode) -> Array[Array[String]] raise CrateError {
match lookup(top, "announce-list") {
Some(tiers) => {
let result : Array[Array[String]] = []
for tier in as_list(tiers) {
let urls : Array[String] = []
for url in as_list(tier) {
urls.push(as_text(url))
}
result.push(urls)
}
result
}
None =>
match lookup(top, "announce") {
Some(url) => [[as_text(url)]]
None => []
}
}
}
///|
fn read_web_seeds(top : BNode) -> Array[String] raise CrateError {
match lookup(top, "url-list") {
Some({ value: Blob(_), .. } as value) => [as_text(value)]
Some(value) => {
let out : Array[String] = []
for item in as_list(value) {
out.push(as_text(item))
}
out
}
None => []
}
}
///|
fn top_fields(info : BNode, options : CreateOptions) -> Array[(String, BNode)] {
let fields = [("info", info), ("created by", bn_text("SwarmCrate 0.14.0"))]
if !options.trackers.is_empty() {
fields.push(("announce", bn_text(options.trackers[0][0])))
fields.push(
(
"announce-list",
bn(
Sequence(
options.trackers.map(fn(tier) { bn(Sequence(tier.map(bn_text))) }),
),
),
),
)
}
if !options.web_seeds.is_empty() {
fields.push(("url-list", bn(Sequence(options.web_seeds.map(bn_text)))))
}
if options.comment != "" {
fields.push(("comment", bn_text(options.comment)))
}
fields
}
///|
fn v1_file_node(file : SourceFile) -> BNode {
bn_dict([
("length", bn_int(file.length)),
("path", bn(Sequence(file.path.map(bn_text)))),
])
}
///|
/// Create deterministic metainfo. Read indices refer to the original unsorted inputs.
fn create_v1_torrent(
files : Array[SourceFile],
options : CreateOptions,
read : (Int32, Int64, Int32) -> Bytes raise CrateError,
control : CreateControl?,
) -> Bytes raise CrateError {
let order = sorted_source_indices(files)
let sorted = order.map(fn(i) { files[i] })
let pieces = sha1_pieces(sorted, options.piece_length, (i, offset, n) => {
read(order[i], offset, n)
})
let fields = [
("name", bn_text(options.name)),
("piece length", bn_int(options.piece_length.to_int64())),
("pieces", bn(Blob(pieces))),
]
if options.single_file {
fields.push(("length", bn_int(sorted[0].length)))
} else {
fields.push(("files", bn(Sequence(sorted.map(v1_file_node)))))
}
if options.private_torrent {
fields.push(("private", bn_int(1L)))
}
emit_creation_progress(control, "before_encode")
encode_bencode(bn_dict(top_fields(bn_dict(fields), options)))
}
///|
priv struct ParsedTorrent {
summary : TorrentInfo
pieces : Bytes
roots : Array[V2FileHash]
entries : Array[DataEntry]
}
///|
fn parse_torrent(data : Bytes) -> ParsedTorrent raise CrateError {
let top = decode_bencode(data)
let info = require_key(top, "info")
if lookup(info, "meta version") is Some(_) {
return parse_v2_torrent(data, top, info)
}
let name = as_text(require_key(info, "name"))
let piece = as_int(require_key(info, "piece length"))
if piece < 16384L || piece > 16777216L {
reject("piece_length", "invalid piece length")
}
check_piece_length(piece.to_int())
let (files, entries, single, total) = read_v1_layout(info, name, piece)
let private_flag = match lookup(info, "private") {
None => false
Some(n) => {
let n = as_int(n)
if n != 0L && n != 1L {
reject("metainfo", "private must be 0 or 1")
}
n == 1L
}
}
let trackers = read_trackers(top)
let web_seeds = read_web_seeds(top)
let comment = match lookup(top, "comment") {
Some(c) => as_text(c)
None => ""
}
let options : CreateOptions = {
name,
mode: V1,
piece_length: piece.to_int(),
single_file: single,
private_torrent: private_flag,
trackers,
web_seeds,
comment,
}
validate_options(files, options)
let pieces = as_blob(require_key(info, "pieces"))
if pieces.length().to_int64() != (total + piece - 1L) / piece * 20L {
reject("pieces", "piece hash count does not match payload length")
}
let hash = @crypto.bytes_to_hex_string(
@crypto.sha1(data[info.start:info.end]),
)
{
summary: {
name,
mode: V1,
piece_length: piece.to_int(),
files,
single_file: single,
private_torrent: private_flag,
trackers,
web_seeds,
comment,
info_hash_v1: Some(hash),
info_hash_v2: None,
},
pieces,
roots: [],
entries,
}
}
///|
pub fn inspect_torrent(data : Bytes) -> TorrentInfo raise CrateError {
parse_torrent(data).summary
}
///|
/// Expose expected v1 piece hashes for verification and integration with other clients.
pub fn expected_v1_pieces(data : Bytes) -> Bytes raise CrateError {
parse_torrent(data).pieces
}
///|
pub fn expected_v2_files(data : Bytes) -> Array[V2FileHash] raise CrateError {
parse_torrent(data).roots
}
///|
/// V1 storage size includes virtual v1/hybrid padding, unlike the real file total.
pub fn torrent_storage_bytes(data : Bytes) -> Int64 raise CrateError {
let mut total = 0L
for entry in parse_torrent(data).entries {
total += entry.length
}
total
}
///|
/// Produce only metadata accepted by this library's full syntax and layout checks.
pub fn create_torrent(
files : Array[SourceFile],
options : CreateOptions,
read : (Int32, Int64, Int32) -> Bytes raise CrateError,
) -> Bytes raise CrateError {
create_torrent_impl(files, options, read, None, plan_torrent(files, options))
}
///|
fn create_torrent_impl(
files : Array[SourceFile],
options : CreateOptions,
read : (Int32, Int64, Int32) -> Bytes raise CrateError,
on_progress : ((CreationProgress) -> Bool raise CrateError)?,
plan : TorrentPlan,
) -> Bytes raise CrateError {
let control = on_progress.map(fn(callback) {
CreateControl::{ callback, plan, reads: 0, bytes: 0L, }
})
emit_creation_progress(control, "start")
let observed_read = match control {
None => read
Some(control) =>
fn(i, offset, n) raise CrateError {
emit_creation_progress(
Some(control),
"before_read",
file=Some(i),
offset=Some(offset),
length=Some(n),
)
control.reads += 1
let data = read_exact(read, i, offset, n)
control.bytes += n.to_int64()
emit_creation_progress(
Some(control),
"after_read",
file=Some(i),
offset=Some(offset),
length=Some(n),
)
data
}
}
let data = match options.mode {
V1 => create_v1_torrent(files, options, observed_read, control)
V2 => create_v2_torrent(files, options, observed_read, control)
Hybrid => create_hybrid_torrent(files, options, observed_read, control)
}
ignore(inspect_torrent(data))
emit_creation_progress(
control,
"finalize",
metainfo_bytes=Some(data.length()),
)
data
}