Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 104 additions & 33 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,43 @@ import queueMicrotask from 'queue-microtask'
* @return {Object}
*/
async function parseTorrent (torrentId) {
if (typeof torrentId === 'string' && /^(stream-)?magnet:/.test(torrentId)) {
// if magnet uri (string)
const torrentObj = magnet(torrentId)

// infoHash won't be defined if a non-bittorrent magnet is passed
if (!torrentObj.infoHash) {
throw new Error('Invalid torrent identifier')
if (typeof torrentId === 'string') {
if (/^(stream-)?magnet:/.test(torrentId)) {
// if magnet uri (string)
const torrentObj = magnet(torrentId)

// infoHash (v1) or infoHashV2 (v2) won't be defined if a non-bittorrent magnet is passed
if (!torrentObj.infoHash && !torrentObj.infoHashV2) {
throw new Error('Invalid torrent identifier')
}

return torrentObj
} else if (/^[a-f0-9]{40}$/i.test(torrentId) || /^[a-z2-7]{32}$/i.test(torrentId)) {
// if info hash v1 (hex/base-32 string)
return magnet(`magnet:?xt=urn:btih:${torrentId}`)
} else if (/^[a-f0-9]{64}$/i.test(torrentId)) {
// if info hash v2 (hex string)
return magnet(`magnet:?xt=urn:btmh:1220${torrentId}`)
}

return torrentObj
} else if (typeof torrentId === 'string' && (/^[a-f0-9]{40}$/i.test(torrentId) || /^[a-z2-7]{32}$/i.test(torrentId))) {
// if info hash (hex/base-32 string)
return magnet(`magnet:?xt=urn:btih:${torrentId}`)
} else if (ArrayBuffer.isView(torrentId) && torrentId.length === 20) {
// if info hash (buffer)
return magnet(`magnet:?xt=urn:btih:${arr2hex(torrentId)}`)
} else if (ArrayBuffer.isView(torrentId)) {
// if .torrent file (buffer)
return await decodeTorrentFile(torrentId) // might throw
} else if (torrentId && torrentId.infoHash) {
if (torrentId.length === 20) {
// if info hash v1 (buffer)
return magnet(`magnet:?xt=urn:btih:${arr2hex(torrentId)}`)
} else if (torrentId.length === 32) {
// if info hash v2 (buffer)
return magnet(`magnet:?xt=urn:btmh:1220${arr2hex(torrentId)}`)
} else {
// if .torrent file (buffer)
return await decodeTorrentFile(torrentId) // might throw
}
} else if (torrentId && (torrentId.infoHash || torrentId.infoHashV2)) {
// if parsed torrent (from `parse-torrent` or `magnet-uri`)
torrentId.infoHash = torrentId.infoHash.toLowerCase()
if (torrentId.infoHash) {
torrentId.infoHash = torrentId.infoHash.toLowerCase()
}
if (torrentId.infoHashV2) {
torrentId.infoHashV2 = torrentId.infoHashV2.toLowerCase()
}

if (!torrentId.announce) torrentId.announce = []

Expand Down Expand Up @@ -63,7 +78,7 @@ async function parseTorrentRemote (torrentId, opts, cb) {
// filesystem path, so don't consider it an error yet.
}

if (parsedTorrent && parsedTorrent.infoHash) {
if (parsedTorrent && (parsedTorrent.infoHash || parsedTorrent.infoHashV2)) {
queueMicrotask(() => {
cb(null, parsedTorrent)
})
Expand Down Expand Up @@ -104,7 +119,7 @@ async function parseTorrentRemote (torrentId, opts, cb) {
} catch (err) {
return cb(err)
}
if (parsedTorrent && parsedTorrent.infoHash) cb(null, parsedTorrent)
if (parsedTorrent && (parsedTorrent.infoHash || parsedTorrent.infoHashV2)) cb(null, parsedTorrent)
else cb(new Error('Invalid torrent identifier'))
}
}
Expand All @@ -123,15 +138,29 @@ async function decodeTorrentFile (torrent) {
ensure(torrent.info, 'info')
ensure(torrent.info['name.utf-8'] || torrent.info.name, 'info.name')
ensure(torrent.info['piece length'], 'info[\'piece length\']')
ensure(torrent.info.pieces, 'info.pieces')

if (torrent.info.files) {
torrent.info.files.forEach(file => {
ensure(typeof file.length === 'number', 'info.files[0].length')
ensure(file['path.utf-8'] || file.path, 'info.files[0].path')
})
} else {
ensure(typeof torrent.info.length === 'number', 'info.length')
const isV2 = torrent.info['meta version'] === 2
const hasV1Structure = !!(torrent.info.pieces || torrent.info.files || typeof torrent.info.length === 'number')
const hasV2Structure = !!torrent.info['file tree']
Comment thread
leoherzog marked this conversation as resolved.
Outdated

// BitTorrent v2 validation (when v2 structures present)
if (isV2 || hasV2Structure) {
Comment thread
leoherzog marked this conversation as resolved.
Outdated
ensure(torrent.info['file tree'], 'info[\'file tree\']')
Comment thread
leoherzog marked this conversation as resolved.
ensure(torrent['piece layers'], 'piece layers')
}

// BitTorrent v1 validation (when v1 structures present)
if (hasV1Structure) {
ensure(torrent.info.pieces, 'info.pieces')

if (torrent.info.files) {
torrent.info.files.forEach(file => {
ensure(typeof file.length === 'number', 'info.files[0].length')
ensure(file['path.utf-8'] || file.path, 'info.files[0].path')
})
} else {
ensure(typeof torrent.info.length === 'number', 'info.length')
}
}

const result = {
Expand All @@ -141,8 +170,21 @@ async function decodeTorrentFile (torrent) {
announce: []
}

result.infoHashBuffer = await hash(result.infoBuffer)
result.infoHash = arr2hex(result.infoHashBuffer)
// Auto-detect hash generation based on torrent type
const hasFileTree = hasV2Structure

const shouldGenerateV1 = hasV1Structure
const shouldGenerateV2 = isV2 || hasFileTree

if (shouldGenerateV1) {
result.infoHashBuffer = await hash(result.infoBuffer)
result.infoHash = arr2hex(result.infoHashBuffer)
}

if (shouldGenerateV2) {
result.infoHashV2Buffer = await hash(result.infoBuffer, undefined, 'sha-256')
result.infoHashV2 = arr2hex(result.infoHashV2Buffer)
}
Comment thread
leoherzog marked this conversation as resolved.
Outdated

if (torrent.info.private !== undefined) result.private = !!torrent.info.private

Expand Down Expand Up @@ -176,7 +218,29 @@ async function decodeTorrentFile (torrent) {
result.urlList = Array.from(new Set(result.urlList))

let sum = 0
const files = torrent.info.files || [torrent.info]
// Create normalized files array for result without modifying original torrent
let files
if (hasV2Structure && !hasV1Structure) {
// Pure v2: flatten file tree for result.files (don't modify torrent.info.files)
files = []
function processFileTree (tree, currentPath = []) {
Comment thread
leoherzog marked this conversation as resolved.
Outdated
for (const [name, entry] of Object.entries(tree)) {
const fullPath = [...currentPath, name]
if (entry.length !== undefined) {
Comment thread
leoherzog marked this conversation as resolved.
Outdated
files.push({
'path.utf-8': fullPath,
length: entry.length
})
} else {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am not sure if this package handles BEP-47 file attributes, but they are pretty important for hybrid torrents, specifically for padfiles. maybe we could just spread the entry object here?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would be my main requested change^

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this? 2ad638f

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, exactly

processFileTree(entry, fullPath)
}
}
}
processFileTree(torrent.info['file tree'])
} else {
// v1 or hybrid: use existing files structure
files = torrent.info.files || [torrent.info]
}
result.files = files.map((file, i) => {
const parts = [].concat(result.name, file['path.utf-8'] || file.path || []).map(p => ArrayBuffer.isView(p) ? arr2text(p) : p)
sum += file.length
Expand All @@ -194,7 +258,14 @@ async function decodeTorrentFile (torrent) {

result.pieceLength = torrent.info['piece length']
result.lastPieceLength = ((lastFile.offset + lastFile.length) % result.pieceLength) || result.pieceLength
result.pieces = splitPieces(torrent.info.pieces)

// Simplified pieces handling - fall back to v1 logic for both
if (torrent.info.pieces) {
result.pieces = splitPieces(torrent.info.pieces)
} else {
// For v2 torrents without pieces, create empty array
result.pieces = []
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not do this: this makes it ambiguous whether the torrent is improperly created (it actually has an empty pieces string) or just a v2-only torrent. imo pieces should remain unset for v2 torrents, and downstream code that depends on it being present should be forced to upgrade if they are handling v2-only torrents: previously this function would just throw for v2-only torrents, but now it doesn't, and so downstream code should be forced to throw if it doesn't support v2-only torrents.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. d9204a7


return result
}
Expand Down
93 changes: 93 additions & 0 deletions test/bittorrent-v2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import fs from 'fs'
import parseTorrent from '../index.js'
import test from 'tape'

test('Test BitTorrent v2 hash support', async t => {
let parsed

// v2 info hash (as a hex string - 64 characters)
const v2Hash = 'a'.repeat(64)
parsed = await parseTorrent(v2Hash)
t.equal(parsed.infoHashV2, v2Hash.toLowerCase())
Comment thread
leoherzog marked this conversation as resolved.
t.equal(parsed.name, undefined)
t.deepEqual(parsed.announce, [])

// v2 info hash (as a Buffer - 32 bytes)
const v2HashBuffer = Buffer.from(v2Hash, 'hex')
parsed = await parseTorrent(v2HashBuffer)
t.equal(parsed.infoHashV2, v2Hash.toLowerCase())

// magnet uri with v2 hash (btmh)
const magnetV2 = `magnet:?xt=urn:btmh:1220${v2Hash}`
parsed = await parseTorrent(magnetV2)
t.ok(parsed.infoHashV2)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not the same assert equal in all of the cases?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaned up testing in 38f7f43


// parsed torrent with both v1 and v2 hashes (hybrid)
const torrentObjHybrid = {
infoHash: 'd2474e86c95b19b8bcfdb92bc12c9d44667cfa36',
infoHashV2: v2Hash
}
parsed = await parseTorrent(torrentObjHybrid)
t.equal(parsed.infoHash, 'd2474e86c95b19b8bcfdb92bc12c9d44667cfa36')
t.equal(parsed.infoHashV2, v2Hash.toLowerCase())

t.end()
})
Comment thread
leoherzog marked this conversation as resolved.

test('Parse BitTorrent v2 torrent files', async t => {
const v2Buf = fs.readFileSync('./test/torrents/bittorrent-v2-test.torrent')
const hybridBuf = fs.readFileSync('./test/torrents/bittorrent-v2-hybrid-test.torrent')

// Test v2 torrent (should auto-detect and generate v2 hash)
const v2Parsed = await parseTorrent(v2Buf)
t.ok(v2Parsed.infoHashV2, 'v2 torrent should have v2 hash')
t.equal(v2Parsed.infoHashV2.length, 64, 'v2 hash should be 64 chars')
Comment thread
leoherzog marked this conversation as resolved.
Outdated

// Test hybrid torrent (should auto-detect and generate both hashes)
const hybrid = await parseTorrent(hybridBuf)
t.ok(hybrid.infoHash, 'Hybrid should have v1 hash')
t.ok(hybrid.infoHashV2, 'Hybrid should have v2 hash')
t.equal(hybrid.infoHash.length, 40, 'v1 hash should be 40 chars')
t.equal(hybrid.infoHashV2.length, 64, 'v2 hash should be 64 chars')

// All should have standard properties
;[v2Parsed, hybrid].forEach(parsed => {
t.ok(parsed.name, 'Should have name')
t.ok(Array.isArray(parsed.files), 'Should have files array')
t.ok(typeof parsed.length === 'number', 'Should have length')
})

t.end()
})

test('Test auto-detection behavior', async t => {
const torrentBuf = fs.readFileSync('./test/torrents/bittorrent-v2-test.torrent')

// Test that v2 torrent auto-detects and generates appropriate hashes
const parsed = await parseTorrent(torrentBuf)
t.ok(parsed.infoHashV2, 'v2 torrent should auto-generate v2 hash')
Comment thread
leoherzog marked this conversation as resolved.
Outdated

Comment thread
leoherzog marked this conversation as resolved.
t.end()
})

test('Test validation requires either v1 or v2 hash', async t => {
// Test that magnet with no valid hash fails
try {
await parseTorrent('magnet:?xt=urn:invalid:123')
t.fail('Should have thrown error for invalid magnet')
} catch (err) {
t.ok(err instanceof Error)
t.ok(err.message.includes('Invalid torrent identifier'))
}

// Test that object with neither hash fails
try {
await parseTorrent({ name: 'test' })
t.fail('Should have thrown error for object without hashes')
} catch (err) {
t.ok(err instanceof Error)
t.ok(err.message.includes('Invalid torrent identifier'))
}

t.end()
})
Binary file added test/torrents/bittorrent-v2-hybrid-test.torrent
Binary file not shown.
Binary file added test/torrents/bittorrent-v2-test.torrent
Binary file not shown.