diff --git a/README.md b/README.md index 05c087f..84a4a10 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ TypeScript, installed as executables on `PATH`. | [`favicon`](#favicon) | Every icon a site links, rendered from one SVG | | [`vid`](#vid) | Inspect, thumbnail, clip and shrink video, through ffmpeg | | [`dl`](#dl) | Download a video, or just its audio, through yt-dlp | +| [`torrent`](#torrent) | Make a torrent out of a directory, and get it seeded | | [`codeburn`](#codeburn) | See where your AI spend goes, by task, tool, model and project | | [`shorten`](#shorten) | Mint a short link on the pit, and follow it from `/f/` | @@ -44,6 +45,9 @@ One thing here is not a `PATH` command and does not need Node: best single stream and says so - **[`yt-dlp`](https://github.com/yt-dlp/yt-dlp)** — `dl` only (`moshcode install yt-dlp`, `pipx install yt-dlp`, `brew install yt-dlp`) +- **[`create-torrent`](https://www.npmjs.com/package/create-torrent)** — `torrent` + only (`npm i -g create-torrent`); `torrent seed` additionally needs + [torlnk](https://www.npmjs.com/package/torlnk) running - **ImageMagick** (`magick`) — `img` only, and only for what sharp cannot do (PDF, PSD, animated GIF); sharp ships with this repo as an optional dependency - **Network on first use** — `favicon` only: the generation is @@ -807,6 +811,56 @@ merge. `dl audio` is `-x`, which *is* ffmpeg, so there it is a hard requirement. A URL that fails does not stop the ones after it; the exit code still reports the failure. +### `torrent` + +Turn a directory into a torrent, and get it seeded: + +```sh +torrent create ./album # writes album.torrent, prints the magnet +torrent seed ./album # …and hands the magnet to torlnk +torrent magnet album.torrent # the magnet for one you already have +torrent info album.torrent # name, info hash, magnet +``` + +`create-torrent` writes a .torrent and never prints a hash; torlnk takes a +magnet rather than a file. So the two do not actually meet without something in +between, and that is what this is. The info hash is computed here — a SHA-1 over +the bencoded `info` dictionary, read out of the file verbatim — rather than by +adding a bencode parser as a dependency for forty lines. + +**Trackers matter more than they look.** A browser can only ever be a WebRTC +peer, so a torrent with no `wss://` tracker is invisible to every web player: +it is on the DHT, desktop clients find it, and the browser sees a torrent with +no peers — which reads as a dead torrent rather than as a missing tracker. The +default list carries both kinds, and every entry was checked rather than copied. +The announce list the WebTorrent tooling ships by default still names +`tracker.leechers-paradise.org` (no DNS at all), `coppersurfer.tk` and +`empire-js.us` (both time out on a UDP connect), and `tracker.btorrent.xyz` +(a self-signed certificate, which a browser refuses outright). Override the lot +with `--tracker`. + +`--private` exists and is opt-in, because a private torrent is excluded from the +DHT by every client that honours the flag — the opposite of the reason to make +one here. + +**From a URL rather than a seeding process.** `--webseed` embeds HTTP URLs that +already serve the same bytes (BEP 19), so the torrent is downloadable the moment +it exists — before any peer has it, and without anything staying running: + +```sh +torrent create ./album --webseed https://files.example.com/album +``` + +The URL has to serve the *exact* bytes the torrent was made from. A redirect to +a re-encoded or recompressed copy is a torrent that fails its hash check, which +looks like corruption rather than like a misconfigured seed. + +`seed` hands the magnet to torlnk, which is the process that stays running: +its serve API by default (`--api`, `$TORLINK_API`, `$TORLINK_API_TOKEN`), or a +watch directory (`--watch`, `$TORLINK_WATCH`) as the offline handoff. How long +it seeds for is a torlnk daemon setting (`--seed-time`), not a per-torrent one; +left alone, it seeds indefinitely. + ### `codeburn` Where your AI spend actually went — [codeburn](https://www.npmjs.com/package/codeburn), diff --git a/bin/torrent.ts b/bin/torrent.ts new file mode 100755 index 0000000..9232cde --- /dev/null +++ b/bin/torrent.ts @@ -0,0 +1,197 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * torrent -- turn a directory into a torrent, and get it seeded. + * + * Two programs do the work, in the same way `dl` leans on yt-dlp and `vid` on + * ffmpeg: `create-torrent` writes the .torrent, and torlnk seeds it. What this + * adds is the part neither of them does -- handing you the magnet. + * + * `create-torrent` writes a file and never prints a hash, and torlnk takes a + * magnet rather than a file, so the two do not actually meet without something + * in between. src/torrent.ts is that something. + */ + +import { spawn } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { basename, join, resolve } from 'node:path'; + +import { UsageError, csv, parseArgs } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { MissingEngineError, findBinary, humanBytes } from '../src/media.ts'; +import { + DEFAULT_TORLINK_API, + DEFAULT_TRACKERS, + addBody, + createArgs, + infoHash, + magnetFor, + torrentName, +} from '../src/torrent.ts'; + +const USAGE = `Usage: + torrent create [-o OUT.torrent] make a torrent, print its magnet + torrent seed make it, then hand it to torlnk + torrent magnet the magnet for a torrent you have + torrent info what is inside one + +Options: + -o, --out PATH where to write the .torrent (default: .torrent) + --tracker URLS comma-separated announce list, replacing the default + --name NAME torrent name, if not the directory's own + --comment TEXT a comment to embed + --private exclude it from the DHT (opt-in; the opposite of sharing) + --webseed URLS comma-separated HTTP URLs already serving this data + --api URL torlnk's serve API (default: ${DEFAULT_TORLINK_API}) + --watch DIR a torlnk watch directory, instead of its API + --json machine-readable output + --help show this help + +Trackers matter more than they look. A browser can only be a peer over WebRTC, +so a torrent with no wss:// tracker is invisible to every web player -- it is on +the DHT, desktop clients find it, and the browser sees a torrent with no peers. +The default list carries both kinds, and every entry in it was checked rather +than copied: the announce list the WebTorrent tooling ships by default still +names three trackers that are dead and one with a self-signed certificate. + +Needs \`create-torrent\` on PATH (npm i -g create-torrent). \`seed\` also needs +torlnk running -- either its serve API, or a watch directory. +`; + +/** Run a child with our stdio, and resolve its exit code. */ +function passthrough(file: string, args: string[]): Promise { + return new Promise((done) => { + const child = spawn(file, args, { stdio: 'inherit' }); + child.on('error', () => done(127)); + child.on('close', (code) => done(code ?? 1)); + }); +} + +if (isMain(import.meta.url)) { + try { + const { flags, values, positional } = parseArgs(process.argv.slice(2), { + boolean: ['--help', '--private', '--json'], + string: ['-o', '--out', '--tracker', '--name', '--comment', '--api', '--watch', '--webseed'], + }); + + if (flags.has('--help') || positional.length === 0) { + process.stdout.write(USAGE); + process.exit(positional.length === 0 ? 1 : 0); + } + + const [command, target] = positional; + if (!target) throw new UsageError(`${command} needs a path`); + + const trackers = values.has('--tracker') ? csv(values, '--tracker') : DEFAULT_TRACKERS; + if (trackers.length === 0) throw new UsageError('--tracker was given no URLs'); + + // Reading an existing torrent needs nothing installed at all. + if (command === 'magnet' || command === 'info') { + const buf = await readFile(target); + if (command === 'magnet') { + process.stdout.write(`${magnetFor(buf, trackers)}\n`); + process.exit(0); + } + const out = { + name: torrentName(buf), + infoHash: infoHash(buf), + size: humanBytes(buf.length), + magnet: magnetFor(buf, trackers), + }; + process.stdout.write( + flags.has('--json') + ? `${JSON.stringify(out, null, 2)}\n` + : `${out.name}\n${out.infoHash}\n${out.magnet}\n`, + ); + process.exit(0); + } + + if (command !== 'create' && command !== 'seed') { + throw new UsageError(`unknown command "${command}"`); + } + + const creator = await findBinary(['create-torrent'], '--help'); + if (!creator) { + throw new MissingEngineError( + 'create-torrent is not on PATH. Install it (npm i -g create-torrent).', + ); + } + + const out = resolve( + values.get('-o') ?? values.get('--out') ?? `${values.get('--name') ?? basename(resolve(target))}.torrent`, + ); + + const code = await passthrough( + creator, + createArgs(target, { + out, + trackers, + ...(values.has('--name') ? { name: values.get('--name') as string } : {}), + ...(values.has('--comment') ? { comment: values.get('--comment') as string } : {}), + isPrivate: flags.has('--private'), + ...(values.has('--webseed') ? { webSeeds: csv(values, '--webseed') } : {}), + }), + ); + if (code !== 0) throw new Error(`create-torrent exited ${code}`); + + const buf = await readFile(out); + const magnet = magnetFor(buf, trackers); + const hash = infoHash(buf); + + if (command === 'create') { + process.stdout.write( + flags.has('--json') + ? `${JSON.stringify({ torrent: out, infoHash: hash, magnet }, null, 2)}\n` + : `${out}\n${magnet}\n`, + ); + process.exit(0); + } + + // seed: hand the magnet to torlnk, which is the thing that stays running. + // + // A watch directory is the offline handoff -- drop the file, torlnk picks it + // up whenever it next looks -- and the API is the online one. The API is + // tried first when either is available, because it answers. + const watch = values.get('--watch') ?? process.env.TORLINK_WATCH; + const api = values.get('--api') ?? process.env.TORLINK_API ?? (watch ? null : DEFAULT_TORLINK_API); + + if (api) { + const res = await fetch(`${api.replace(/\/$/, '')}/add`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + // torlnk requires a token only when it is bound to a public address; + // sending one it did not ask for is harmless, so this is set-and-forget. + ...(process.env.TORLINK_API_TOKEN + ? { authorization: `Bearer ${process.env.TORLINK_API_TOKEN}` } + : {}), + }, + body: addBody(magnet), + }).catch((err: Error) => { + throw new MissingEngineError( + `torlnk's API did not answer at ${api} (${err.message}). Start it with \`torlnk serve --daemon\`, or pass --watch .`, + ); + }); + if (!res.ok) throw new Error(`torlnk answered ${res.status} ${res.statusText}`); + process.stdout.write(`${out}\n${magnet}\nqueued with torlnk at ${api}\n`); + process.exit(0); + } + + // A .magnet file is what torlnk's watch mode reads; the .torrent would work + // too, but the magnet carries the tracker list we just chose. + const dropped = join(resolve(watch as string), `${basename(out, '.torrent')}.magnet`); + await writeFile(dropped, `${magnet}\n`, 'utf8'); + process.stdout.write(`${out}\n${magnet}\ndropped for torlnk at ${dropped}\n`); + process.exit(0); + } catch (err) { + if (err instanceof MissingEngineError) { + process.stderr.write(`torrent: ${err.message}\n`); + process.exit(2); + } + if (err instanceof UsageError) { + process.stderr.write(`torrent: ${err.message}\n\n${USAGE}`); + process.exit(1); + } + process.stderr.write(`torrent: ${(err as Error).message}\n`); + process.exit(1); + } +} diff --git a/package.json b/package.json index 4a940e9..37ca1ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.13.0", + "version": "0.14.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/registry.ts b/src/registry.ts index a2c3fad..3e2c1ff 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -45,6 +45,7 @@ const SUMMARIES: Record = { porkbun: 'Read and change DNS at Porkbun, and un-park a domain', shorten: 'Mint a short link on the pit, and follow it from /f/', tcfeed: 'Find repositories worth scanning, scan them, print a shortlist', + torrent: 'Make a torrent out of a directory, and get it seeded', tts: 'Read text aloud and keep the audio', vid: 'Inspect, thumbnail, clip and shrink video, through ffmpeg', }; diff --git a/src/torrent.ts b/src/torrent.ts new file mode 100644 index 0000000..a4c23e0 --- /dev/null +++ b/src/torrent.ts @@ -0,0 +1,199 @@ +/** + * Making a torrent out of a directory, and getting it seeded. + * + * Two programs do the work and neither is bundled: `create-torrent` writes the + * .torrent, and torlnk seeds it. Everything here is the part in between -- + * argv, the info hash, the magnet, and the trackers -- because that part can be + * reasoned about and tested without a network or a peer. + * + * The info hash is computed here rather than read from a library on purpose. + * `create-torrent` writes a file and does not tell you the hash, and the only + * thing standing between that file and a magnet URI is a SHA-1 over the + * bencoded `info` dictionary. Pulling in a bencode parser to find one span in a + * buffer would be a dependency for forty lines, in a repository whose only + * dependency is optional. + */ + +import { createHash } from 'node:crypto'; + +/** + * The trackers a new torrent announces to, and why these ones. + * + * Both halves are load-bearing and they serve different peers: + * + * wss:// the only way a browser can be a peer. A page running WebTorrent + * speaks WebRTC and nothing else, so a torrent with no WSS tracker + * is invisible to every web player -- it is on the DHT, desktop + * clients find it, and the browser sees a torrent with no peers. + * udp:// everything else, and the reason a DHT crawler notices you exist + * in the first place. + * + * The list is short because it was checked rather than copied. The default + * announce list shipped by the WebTorrent tooling still carries + * tracker.leechers-paradise.org (no DNS at all), coppersurfer.tk and + * empire-js.us (both time out on a UDP connect), and tracker.btorrent.xyz + * (a self-signed certificate, so a browser refuses it outright). Every entry + * below completed a real handshake -- a WebSocket open, or a UDP connect that + * came back with a connection id. + */ +export const WSS_TRACKERS = [ + 'wss://tracker.openwebtorrent.com', + 'wss://tracker.webtorrent.dev', +]; + +export const UDP_TRACKERS = [ + 'udp://tracker.opentrackr.org:1337/announce', + 'udp://open.demonii.com:1337/announce', + 'udp://tracker.torrent.eu.org:451/announce', + 'udp://explodie.org:6969/announce', +]; + +export const DEFAULT_TRACKERS = [...WSS_TRACKERS, ...UDP_TRACKERS]; + +/** The argv for `create-torrent`. */ +export function createArgs( + path: string, + { out, trackers = DEFAULT_TRACKERS, name, comment, isPrivate = false, webSeeds = [] }: { + out?: string; + trackers?: readonly string[]; + name?: string; + comment?: string; + isPrivate?: boolean; + /** HTTP URLs that already serve this exact data (BEP 19 web seeds). */ + webSeeds?: readonly string[]; + } = {}, +): string[] { + const args = [path]; + if (out) args.push('-o', out); + if (name) args.push('-n', name); + if (comment) args.push('--comment', comment); + // A private torrent is excluded from the DHT by every client that honours the + // flag, which is the opposite of the point here -- so it is opt-in and named. + if (isPrivate) args.push('--private'); + for (const tracker of trackers) args.push('--announce', tracker); + // A web seed is a plain HTTP URL every peer can also pull bytes from, so a + // torrent with one is downloadable the moment it exists -- before any peer + // has it, and without a seeding process at all. The URL has to serve the + // exact bytes the torrent was made from; a redirect to a different encoding + // is a torrent that fails its hash check rather than one that is merely slow. + for (const url of webSeeds) args.push('--urlList', url); + return args; +} + +/** + * The end offset of the bencoded value that starts at `at`. + * + * Bencode is four shapes and each one says where it ends, so finding a span + * never needs the value itself: `i…e` is an integer, `:` a string, + * `l…e` a list and `d…e` a dictionary, the last two holding more of the same. + */ +export function spanEnd(buf: Buffer, at: number): number { + const byte = buf[at]; + if (byte === undefined) throw new Error('truncated torrent: ran off the end'); + + const I = 0x69; // 'i' + const L = 0x6c; // 'l' + const D = 0x64; // 'd' + const E = 0x65; // 'e' + const COLON = 0x3a; + + if (byte === I) { + const end = buf.indexOf(E, at + 1); + if (end === -1) throw new Error('truncated torrent: unterminated integer'); + return end + 1; + } + + if (byte === L || byte === D) { + let cursor = at + 1; + while (buf[cursor] !== E) { + if (cursor >= buf.length) throw new Error('truncated torrent: unterminated container'); + cursor = spanEnd(buf, cursor); + } + return cursor + 1; + } + + // A string: decimal length, a colon, then exactly that many bytes. + const colon = buf.indexOf(COLON, at); + if (colon === -1) throw new Error('truncated torrent: unterminated string length'); + const length = Number(buf.toString('ascii', at, colon)); + if (!Number.isInteger(length) || length < 0) { + throw new Error(`not a torrent: bad string length at byte ${at}`); + } + return colon + 1 + length; +} + +/** + * The bytes of the `info` dictionary, exactly as they appear in the file. + * + * Exactly as they appear is the whole requirement: the info hash is a SHA-1 + * over the original bytes, so decoding the dictionary and re-encoding it would + * produce a different hash the moment a client wrote its keys in an order or a + * form we did not reproduce. + */ +export function infoSection(buf: Buffer): Buffer { + if (buf[0] !== 0x64) throw new Error('not a torrent: the file does not start with a dictionary'); + + let cursor = 1; + while (cursor < buf.length && buf[cursor] !== 0x65) { + const keyEnd = spanEnd(buf, cursor); + const colon = buf.indexOf(0x3a, cursor); + const key = buf.toString('utf8', colon + 1, keyEnd); + const valueEnd = spanEnd(buf, keyEnd); + if (key === 'info') return buf.subarray(keyEnd, valueEnd); + cursor = valueEnd; + } + throw new Error('not a torrent: no info dictionary'); +} + +/** The info hash of a .torrent file's bytes, lowercase hex. */ +export function infoHash(buf: Buffer): string { + return createHash('sha1').update(infoSection(buf)).digest('hex'); +} + +/** The `name` a torrent gives itself, for the magnet's display name. */ +export function torrentName(buf: Buffer): string | null { + const info = infoSection(buf); + let cursor = 1; + while (cursor < info.length && info[cursor] !== 0x65) { + const keyEnd = spanEnd(info, cursor); + const colon = info.indexOf(0x3a, cursor); + const key = info.toString('utf8', colon + 1, keyEnd); + const valueEnd = spanEnd(info, keyEnd); + if (key === 'name') { + const valueColon = info.indexOf(0x3a, keyEnd); + return info.toString('utf8', valueColon + 1, valueEnd); + } + cursor = valueEnd; + } + return null; +} + +/** A magnet URI. Trackers are `tr` parameters, in the order given. */ +export function magnetUri({ + hash, + name, + trackers = [], +}: { + hash: string; + name?: string | null; + trackers?: readonly string[]; +}): string { + if (!/^[0-9a-f]{40}$/i.test(hash)) throw new Error(`not an info hash: ${hash}`); + const parts = [`magnet:?xt=urn:btih:${hash.toLowerCase()}`]; + if (name) parts.push(`dn=${encodeURIComponent(name)}`); + for (const tracker of trackers) parts.push(`tr=${encodeURIComponent(tracker)}`); + return parts.join('&'); +} + +/** Everything a magnet needs, read straight out of a .torrent file. */ +export function magnetFor(buf: Buffer, trackers: readonly string[] = DEFAULT_TRACKERS): string { + return magnetUri({ hash: infoHash(buf), name: torrentName(buf), trackers }); +} + +/** The body torlnk's `serve` API takes on POST /add. */ +export function addBody(magnet: string): string { + return JSON.stringify({ magnet }); +} + +/** Where torlnk's serve API listens unless told otherwise. */ +export const DEFAULT_TORLINK_API = 'http://127.0.0.1:9161'; diff --git a/test/torrent.test.ts b/test/torrent.test.ts new file mode 100644 index 0000000..7590616 --- /dev/null +++ b/test/torrent.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + DEFAULT_TRACKERS, + UDP_TRACKERS, + WSS_TRACKERS, + addBody, + createArgs, + infoHash, + infoSection, + magnetFor, + magnetUri, + spanEnd, + torrentName, +} from '../src/torrent.ts'; + +/* A minimal bencoder, so the fixtures below are readable rather than hex. */ +const bstr = (s: string) => `${Buffer.byteLength(s)}:${s}`; +const TORRENT = Buffer.from( + `d8:announce${bstr('udp://a.test:1337/announce')}4:infod6:lengthi12e4:name${bstr('thing.txt')}12:piece lengthi16384ee5:extra${bstr('after the info dict')}e`, + 'utf8', +); + +describe('trackers', () => { + /* + * The half of the list that decides whether a web player can see the torrent + * at all. A browser is only ever a WebRTC peer, so with no wss:// tracker the + * torrent is on the DHT, desktop clients find it, and the browser sees a + * torrent with no peers -- which reads as a dead torrent rather than as a + * missing tracker. + */ + it('always announces to both kinds', () => { + expect(WSS_TRACKERS.length).toBeGreaterThan(0); + expect(UDP_TRACKERS.length).toBeGreaterThan(0); + expect(DEFAULT_TRACKERS).toEqual([...WSS_TRACKERS, ...UDP_TRACKERS]); + }); + + /* + * These four ship in the WebTorrent tooling's default announce list and none + * of them answers: leechers-paradise has no DNS at all, coppersurfer and + * empire-js time out on a UDP connect, and btorrent.xyz serves a self-signed + * certificate that a browser refuses outright. + */ + it('carries none of the dead defaults', () => { + const dead = ['leechers-paradise', 'coppersurfer', 'empire-js', 'btorrent.xyz']; + for (const host of dead) { + expect(DEFAULT_TRACKERS.join(' ')).not.toContain(host); + } + }); +}); + +describe('createArgs', () => { + it('repeats --announce once per tracker', () => { + const args = createArgs('dir', { trackers: ['udp://a', 'wss://b'] }); + expect(args.filter((a) => a === '--announce')).toHaveLength(2); + expect(args).toEqual(expect.arrayContaining(['udp://a', 'wss://b'])); + }); + + it('puts the path first, where create-torrent wants it', () => { + expect(createArgs('dir', { out: 'x.torrent' })[0]).toBe('dir'); + }); + + /* + * A private torrent is excluded from the DHT by every client that honours the + * flag, which is the exact opposite of the reason to make one here. + */ + it('is public unless privacy was asked for by name', () => { + expect(createArgs('dir')).not.toContain('--private'); + expect(createArgs('dir', { isPrivate: true })).toContain('--private'); + }); +}); + +describe('reading a torrent', () => { + it('finds the end of each bencoded shape', () => { + expect(spanEnd(Buffer.from('i42e'), 0)).toBe(4); + expect(spanEnd(Buffer.from('4:spam'), 0)).toBe(6); + expect(spanEnd(Buffer.from('l4:spami1ee'), 0)).toBe(11); + expect(spanEnd(Buffer.from('d3:onei1ee'), 0)).toBe(10); + }); + + /* + * The span has to be the ORIGINAL bytes. The info hash is a SHA-1 over them, + * so decoding the dictionary and re-encoding it would produce a different + * hash the moment a client wrote a key in an order or a form we did not + * reproduce -- and the torrent would be a different torrent. + */ + it('takes the info dictionary verbatim, and stops at its end', () => { + const info = infoSection(TORRENT); + expect(info.toString('utf8').startsWith('d6:length')).toBe(true); + expect(info.toString('utf8').endsWith('e')).toBe(true); + expect(info.toString('utf8')).not.toContain('after the info dict'); + }); + + it('reads the name out of the info dictionary', () => { + expect(torrentName(TORRENT)).toBe('thing.txt'); + }); + + it('refuses a file that is not a torrent rather than hashing rubbish', () => { + expect(() => infoHash(Buffer.from('not a torrent'))).toThrow(/not a torrent/); + expect(() => infoHash(Buffer.from('d4:spam'))).toThrow(/truncated|not a torrent/); + }); +}); + +describe('magnetUri', () => { + it('builds xt, dn and one tr per tracker', () => { + const hash = 'a'.repeat(40); + const magnet = magnetUri({ hash, name: 'my thing', trackers: ['udp://a', 'wss://b'] }); + expect(magnet).toContain(`xt=urn:btih:${hash}`); + expect(magnet).toContain('dn=my%20thing'); + expect(magnet.match(/tr=/g)).toHaveLength(2); + // The tracker itself must survive the round trip; a bare & would truncate it. + expect(magnet).toContain(encodeURIComponent('udp://a')); + }); + + it('refuses anything that is not an info hash', () => { + expect(() => magnetUri({ hash: 'nope' })).toThrow(/not an info hash/); + expect(() => magnetUri({ hash: 'a'.repeat(39) })).toThrow(/not an info hash/); + }); +}); + +describe('addBody', () => { + it('is the shape torlnk POST /add takes', () => { + expect(JSON.parse(addBody('magnet:?xt=urn:btih:abc'))).toEqual({ magnet: 'magnet:?xt=urn:btih:abc' }); + }); +}); + +/* + * The assertion the rest of this file rests on. + * + * Everything above is our own arithmetic checked against our own fixture, which + * proves only that it is self-consistent. This hashes a torrent produced by a + * real client and compares against the info hash that client computed: if the + * bencode scan is off by a byte at either end, this is what says so. + */ +describe('against a real client', () => { + it('computes the info hash WebTorrent computes', () => { + const dir = mkdtempSync(join(tmpdir(), 'cli-tools-torrent-')); + const data = join(dir, 'sample'); + mkdirSync(data); + writeFileSync(join(data, 'a.txt'), 'hello torrent world\n'); + + let created: string; + try { + created = join(dir, 'sample.torrent'); + execFileSync('npx', ['--yes', 'create-torrent', data, '-o', created], { + stdio: 'ignore', + timeout: 120_000, + }); + } catch { + // No network, or no npx: the offline assertions above still stand. + return; + } + + const buf = readFileSync(created); + // create-torrent embeds the same info dict WebTorrent would, so its own + // reader is the reference for what we computed. + const expected = execFileSync('npx', ['--yes', 'parse-torrent', created], { + encoding: 'utf8', + timeout: 120_000, + }); + expect(expected).toContain(infoHash(buf)); + expect(magnetFor(buf)).toContain(infoHash(buf)); + }); +}); + +describe('web seeds', () => { + /* + * A web seed is what makes a torrent downloadable before any peer has it: an + * HTTP URL every client can pull the same bytes from, with no seeding process + * in the path at all. + */ + it('adds one --urlList per URL, and none when there are none', () => { + expect(createArgs('dir')).not.toContain('--urlList'); + const args = createArgs('dir', { webSeeds: ['https://a.test/f', 'https://b.test/f'] }); + expect(args.filter((a) => a === '--urlList')).toHaveLength(2); + expect(args).toEqual(expect.arrayContaining(['https://a.test/f', 'https://b.test/f'])); + }); +});