///|
pub(all) enum TorrentMode {
V1
V2
Hybrid
} derive(Eq, Debug)
///|
pub extend TorrentMode with Eq::{equal, not_equal}
///|
pub extend TorrentMode with Debug::{to_repr}
///|
pub(all) struct SourceFile {
path : Array[String]
length : Int64
}
///|
pub(all) struct CreateOptions {
name : String
mode : TorrentMode
piece_length : Int32
single_file : Bool
private_torrent : Bool
trackers : Array[Array[String]]
web_seeds : Array[String]
comment : String
}
///|
pub fn create_options(name : String) -> CreateOptions {
{
name,
mode: Hybrid,
piece_length: 262144,
single_file: false,
private_torrent: false,
trackers: [],
web_seeds: [],
comment: "",
}
}
///|
/// Recommend a power-of-two piece size in 16 KiB..16 MiB. The target is soft;
/// per-file formats cannot use fewer pieces than their nonempty file count.
/// Never recommend a size that would exceed the hard 400,000-piece limit.
pub fn recommend_piece_length(
files : Array[SourceFile],
mode~ : TorrentMode = Hybrid,
target_pieces~ : Int32 = 4096,
) -> Int32 raise CrateError {
let total = validate_sources(files)
if total == 0L {
reject("empty_payload", "cannot select pieces for an empty payload")
}
if target_pieces < 1 || target_pieces > 400000 {
reject("piece_length", "target pieces must be in 1..400000")
}
let mut budget = target_pieces.to_int64()
if mode != V1 {
let mut nonempty = 0L
for file in files {
if file.length > 0L {
nonempty += 1L
}
}
if nonempty > budget {
budget = nonempty
}
}
let mut piece = 16384
while piece <= 16777216 {
let width = piece.to_int64()
let count = if mode == V1 {
(total + width - 1L) / width
} else {
let mut sum = 0L
for file in files {
sum += (file.length + width - 1L) / width
}
sum
}
if count <= budget || (piece == 16777216 && count <= 400000L) {
return piece
}
piece *= 2
}
reject("limit", "payload exceeds the piece limit even at 16 MiB")
0
}
///|
fn check_component(part : String) -> Unit raise CrateError {
if part.is_empty() ||
@utf8.encode(part).length() > 255 ||
part == "." ||
part == ".." {
reject("path", "empty dot traversal or oversized path component")
}
if part.has_suffix(".") || part.has_suffix(" ") {
reject("path", "trailing dot/space is not portable: " + part)
}
for c in part.iter() {
if c.to_int() < 32 || "/\\:*?\"<>|".contains_char(c) {
reject("path", "unsafe path component: " + part)
}
}
let stem = part.split(".").next().unwrap_or("").to_owned().to_lower()
if [
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6",
"com7", "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7",
"lpt8", "lpt9",
].contains(stem) {
reject("path", "reserved device name: " + part)
}
}
///|
/// The creator uses a portable path subset and reserves .pad for hybrid alignment.
pub fn validate_sources(files : Array[SourceFile]) -> Int64 raise CrateError {
if files.is_empty() || files.length() > 65536 {
reject("limit", "expected 1..65536 source files")
}
let paths : Map[String, Bool] = Map([])
let folded : Map[String, Bool] = Map([])
let mut total = 0L
for file in files {
if file.path.is_empty() || file.path.length() > 32 {
reject("path", "expected 1..32 path components")
}
for component in file.path {
check_component(component)
}
if file.path[0].to_lower() == ".pad" {
reject("path", ".pad is reserved for virtual padding")
}
let path = file.path.join("/")
if paths.contains(path) || folded.contains(path.to_lower()) {
reject("path", "duplicate or case-colliding file: " + path)
}
paths.set(path, true)
folded.set(path.to_lower(), true)
if file.length < 0 || file.length > 9007199254740991L - total {
reject(
"limit", "negative length or total exceeds exact host offset range",
)
}
total += file.length
}
for file in files {
let mut prefix = ""
for index in 0..<(file.path.length() - 1) {
prefix = if index == 0 {
file.path[index]
} else {
prefix + "/" + file.path[index]
}
if folded.contains(prefix.to_lower()) {
reject("path", "file/directory path collision: " + prefix)
}
}
}
total
}
///|
fn path_compare(a : Array[String], b : Array[String]) -> Int32 {
let count = if a.length() < b.length() { a.length() } else { b.length() }
for index in 0.. Array[Int32] raise CrateError {
ignore(validate_sources(files))
let indices = Array::makei(files.length(), fn(i) { i })
indices.sort_by(fn(a, b) { path_compare(files[a].path, files[b].path) })
indices
}
///|
fn check_url(url : String, tracker : Bool) -> Unit raise CrateError {
let allowed = url.has_prefix("https://") ||
url.has_prefix("http://") ||
(tracker && url.has_prefix("udp://"))
if !allowed || url.length() > 4096 {
reject("url", "unsupported or oversized URL")
}
for c in url.iter() {
if c.to_int() <= 32 || c.to_int() == 127 {
reject("url", "URL contains whitespace or control characters")
}
}
let rest = url.split_once("://").unwrap().1.to_owned()
let authority = rest
.split("/")
.next()
.unwrap_or("")
.split("?")
.next()
.unwrap_or("")
if rest.is_empty() ||
authority.is_empty() ||
authority.contains("@") ||
authority.has_prefix(":") ||
rest.contains("#") ||
rest.contains("\\") ||
rest.has_prefix("/") ||
rest.has_prefix("?") ||
rest.has_prefix("#") {
reject("url", "URL has no host")
}
}
///|
pub fn validate_options(
files : Array[SourceFile],
options : CreateOptions,
) -> Unit raise CrateError {
let total = validate_sources(files)
if total == 0L {
reject(
"empty_payload", "at least one nonempty file is required for interoperable torrents",
)
}
check_component(options.name)
let piece = options.piece_length
if piece < 16384 || piece > 16777216 || (piece & (piece - 1)) != 0 {
reject("piece_length", "expected a power of two in 16 KiB..16 MiB")
}
if (total + piece.to_int64() - 1) / piece.to_int64() > 400000L {
reject("limit", "too many pieces; select a larger piece length")
}
if options.single_file &&
(
files.length() != 1 ||
files[0].path.length() != 1 ||
files[0].path[0] != options.name
) {
reject("path", "single-file name must match its only source file")
}
if options.private_torrent && options.trackers.is_empty() {
reject("tracker", "private torrents require at least one tracker")
}
if options.trackers.length() > 64 ||
options.web_seeds.length() > 64 ||
options.comment.length() > 16384 {
reject("limit", "too much metadata")
}
for tier in options.trackers {
if tier.is_empty() || tier.length() > 64 {
reject("tracker", "expected 1..64 tracker URLs per tier")
}
for url in tier {
check_url(url, true)
}
}
for url in options.web_seeds {
check_url(url, false)
}
}