bun-torrent 0.0.1-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +49 -0
- package/src/app.ts +65 -0
- package/src/client.error.ts +12 -0
- package/src/client.test.ts +117 -0
- package/src/client.ts +185 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +76 -0
- package/src/peer/__tests__/peer-id.test.ts +24 -0
- package/src/peer/availability.test.ts +50 -0
- package/src/peer/availability.ts +88 -0
- package/src/peer/consts.ts +11 -0
- package/src/peer/handshake/handshake.error.ts +15 -0
- package/src/peer/handshake/handshake.test.ts +125 -0
- package/src/peer/handshake/index.ts +83 -0
- package/src/peer/index.ts +31 -0
- package/src/peer/messages/error.ts +16 -0
- package/src/peer/messages/helpers.ts +36 -0
- package/src/peer/messages/index.ts +174 -0
- package/src/peer/messages/messages.test.ts +231 -0
- package/src/peer/messages/types.ts +76 -0
- package/src/peer/peer-id.ts +9 -0
- package/src/peer/pool/index.ts +301 -0
- package/src/peer/pool/pool.error.ts +17 -0
- package/src/peer/pool/pool.test.ts +305 -0
- package/src/peer/session/index.ts +276 -0
- package/src/peer/session/session.error.ts +19 -0
- package/src/peer/session/session.test.ts +110 -0
- package/src/peer/types.ts +6 -0
- package/src/torrent/bencode/__tests__/decoder.test.ts +212 -0
- package/src/torrent/bencode/__tests__/encoder.test.ts +138 -0
- package/src/torrent/bencode/__tests__/encoder.unit.test.ts +24 -0
- package/src/torrent/bencode/__tests__/integration.test.ts +64 -0
- package/src/torrent/bencode/decoder.error.ts +36 -0
- package/src/torrent/bencode/decoder.ts +180 -0
- package/src/torrent/bencode/encoder.error.ts +14 -0
- package/src/torrent/bencode/encoder.ts +86 -0
- package/src/torrent/bencode/index.ts +9 -0
- package/src/torrent/bencode/types.ts +15 -0
- package/src/torrent/bencode/utils.ts +17 -0
- package/src/torrent/download/index.ts +9 -0
- package/src/torrent/download/manager.test.ts +393 -0
- package/src/torrent/download/manager.ts +376 -0
- package/src/torrent/file-selection.ts +51 -0
- package/src/torrent/index.ts +61 -0
- package/src/torrent/parser/helpers.ts +44 -0
- package/src/torrent/parser/index.ts +197 -0
- package/src/torrent/parser/info-hash.test.ts +39 -0
- package/src/torrent/parser/info-hash.ts +7 -0
- package/src/torrent/parser/parser.error.ts +21 -0
- package/src/torrent/parser/parser.test.ts +286 -0
- package/src/torrent/pieces/DefaultPlanner.ts +257 -0
- package/src/torrent/pieces/index.ts +20 -0
- package/src/torrent/pieces/planner.error.ts +18 -0
- package/src/torrent/pieces/planner.test.ts +303 -0
- package/src/torrent/pieces/planner.ts +16 -0
- package/src/torrent/pieces/types.ts +59 -0
- package/src/torrent/pieces/utils.ts +85 -0
- package/src/torrent/pieces/validation.test.ts +32 -0
- package/src/torrent/pieces/validation.ts +27 -0
- package/src/torrent/session/index.ts +195 -0
- package/src/torrent/session/session.test.ts +279 -0
- package/src/torrent/storage/index.ts +161 -0
- package/src/torrent/storage/storage.error.ts +18 -0
- package/src/torrent/storage/storage.test.ts +326 -0
- package/src/torrent/storage/types.ts +13 -0
- package/src/torrent/types.ts +16 -0
- package/src/tracker/http.test.ts +66 -0
- package/src/tracker/http.ts +137 -0
- package/src/tracker/index.ts +93 -0
- package/src/tracker/tracker.error.ts +26 -0
- package/src/tracker/types.ts +17 -0
- package/src/tracker/udp.test.ts +155 -0
- package/src/tracker/udp.ts +234 -0
- package/src/utils/__tests__/sha1.test.ts +16 -0
- package/src/utils/buffers.ts +28 -0
- package/src/utils/errors.ts +9 -0
- package/src/utils/formats.ts +8 -0
- package/src/utils/sha1.ts +4 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { decodeBencode } from '../bencode';
|
|
2
|
+
import { fail, readText, readInteger, readBytes } from './helpers';
|
|
3
|
+
import { computeInfoHash } from './info-hash';
|
|
4
|
+
import { TorrentParseErrorCode } from './parser.error';
|
|
5
|
+
import type { BDict, BValue } from '../bencode/types';
|
|
6
|
+
import type { TorrentFile, TorrentMetadata } from '../types';
|
|
7
|
+
|
|
8
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
9
|
+
|
|
10
|
+
export const parseTorrent = (input: Uint8Array): TorrentMetadata => {
|
|
11
|
+
const decoded = decodeBencode(input);
|
|
12
|
+
if (!(decoded instanceof Map)) {
|
|
13
|
+
return fail(
|
|
14
|
+
TorrentParseErrorCode.ROOT_NOT_DICT,
|
|
15
|
+
'Torrent root must be a dictionary',
|
|
16
|
+
'root',
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const root = decoded;
|
|
21
|
+
const infoValue = root.get('info');
|
|
22
|
+
if (infoValue === undefined) {
|
|
23
|
+
return fail(TorrentParseErrorCode.INFO_MISSING, 'Missing torrent info dictionary', 'info');
|
|
24
|
+
}
|
|
25
|
+
if (!(infoValue instanceof Map)) {
|
|
26
|
+
return fail(
|
|
27
|
+
TorrentParseErrorCode.INFO_NOT_DICT,
|
|
28
|
+
'Torrent info must be a dictionary',
|
|
29
|
+
'info',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const info = infoValue;
|
|
34
|
+
|
|
35
|
+
const name = readText(info, 'name');
|
|
36
|
+
const pieceLength = readInteger(info, 'piece length');
|
|
37
|
+
const piecesBytes = readBytes(info, 'pieces');
|
|
38
|
+
const files = readFiles(info, name);
|
|
39
|
+
const length = files.reduce((total, file) => total + file.length, 0);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
announce: root.has('announce') ? readText(root, 'announce') : undefined,
|
|
43
|
+
announceList: readAnnounceList(root),
|
|
44
|
+
infoHash: computeInfoHash(info),
|
|
45
|
+
name,
|
|
46
|
+
pieceLength,
|
|
47
|
+
pieces: splitPieces(piecesBytes),
|
|
48
|
+
length,
|
|
49
|
+
files,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const readFiles = (info: BDict, name: string): TorrentFile[] => {
|
|
54
|
+
const filesValue = info.get('files');
|
|
55
|
+
if (filesValue === undefined) {
|
|
56
|
+
const length = readInteger(info, 'length');
|
|
57
|
+
return [{ path: [name], length, offset: 0 }];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!Array.isArray(filesValue)) {
|
|
61
|
+
return fail(TorrentParseErrorCode.FIELD_INVALID, 'Expected files to be a list', 'files');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const files: TorrentFile[] = [];
|
|
65
|
+
let offset = 0;
|
|
66
|
+
|
|
67
|
+
for (let index = 0; index < filesValue.length; index++) {
|
|
68
|
+
const fileValue = filesValue[index]!;
|
|
69
|
+
if (!(fileValue instanceof Map)) {
|
|
70
|
+
return fail(
|
|
71
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
72
|
+
'Expected file entry to be a dictionary',
|
|
73
|
+
`files[${index}]`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const length = readFileLength(fileValue, index);
|
|
78
|
+
const path = readFilePath(fileValue, index);
|
|
79
|
+
|
|
80
|
+
files.push({ path, length, offset });
|
|
81
|
+
offset += length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return files;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const readFileLength = (file: BDict, fileIndex: number): number => {
|
|
88
|
+
const value = file.get('length');
|
|
89
|
+
if (value === undefined) {
|
|
90
|
+
return fail(
|
|
91
|
+
TorrentParseErrorCode.FIELD_MISSING,
|
|
92
|
+
`Missing field: files[${fileIndex}].length`,
|
|
93
|
+
`files[${fileIndex}].length`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (typeof value !== 'number') {
|
|
97
|
+
return fail(
|
|
98
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
99
|
+
`Expected integer: files[${fileIndex}].length`,
|
|
100
|
+
`files[${fileIndex}].length`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return value;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const readFilePath = (file: BDict, fileIndex: number): string[] => {
|
|
108
|
+
const value = file.get('path');
|
|
109
|
+
if (value === undefined) {
|
|
110
|
+
return fail(
|
|
111
|
+
TorrentParseErrorCode.FIELD_MISSING,
|
|
112
|
+
`Missing field: files[${fileIndex}].path`,
|
|
113
|
+
`files[${fileIndex}].path`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
117
|
+
return fail(
|
|
118
|
+
TorrentParseErrorCode.FILE_PATH_INVALID,
|
|
119
|
+
'Expected file path to be a non-empty list',
|
|
120
|
+
`files[${fileIndex}].path`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return value.map((segment, segmentIndex) => {
|
|
125
|
+
if (!(segment instanceof Uint8Array)) {
|
|
126
|
+
return fail(
|
|
127
|
+
TorrentParseErrorCode.FILE_PATH_INVALID,
|
|
128
|
+
'Expected file path segment to be bytes',
|
|
129
|
+
`files[${fileIndex}].path[${segmentIndex}]`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const text = textDecoder.decode(segment);
|
|
134
|
+
if (text.length === 0 || text === '.' || text === '..' || text.includes('/')) {
|
|
135
|
+
return fail(
|
|
136
|
+
TorrentParseErrorCode.FILE_PATH_INVALID,
|
|
137
|
+
'Invalid file path segment',
|
|
138
|
+
`files[${fileIndex}].path[${segmentIndex}]`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return text;
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const readAnnounceList = (root: BDict): string[][] => {
|
|
147
|
+
const value = root.get('announce-list');
|
|
148
|
+
if (value === undefined) return [];
|
|
149
|
+
|
|
150
|
+
if (!Array.isArray(value)) {
|
|
151
|
+
return fail(
|
|
152
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
153
|
+
'Expected announce-list to be a list',
|
|
154
|
+
'announce-list',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return value.map((tier, tierIndex) => readAnnounceTier(tier, tierIndex));
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const readAnnounceTier = (value: BValue, tierIndex: number): string[] => {
|
|
162
|
+
if (!Array.isArray(value)) {
|
|
163
|
+
return fail(
|
|
164
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
165
|
+
'Expected announce-list tier to be a list',
|
|
166
|
+
`announce-list[${tierIndex}]`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return value.map((tracker, trackerIndex) => {
|
|
171
|
+
if (!(tracker instanceof Uint8Array)) {
|
|
172
|
+
return fail(
|
|
173
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
174
|
+
'Expected announce-list tracker to be bytes',
|
|
175
|
+
`announce-list[${tierIndex}][${trackerIndex}]`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return textDecoder.decode(tracker);
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const SHA1_HASH_LENGTH = 20;
|
|
184
|
+
|
|
185
|
+
const splitPieces = (pieces: Uint8Array): Uint8Array[] => {
|
|
186
|
+
if (pieces.byteLength === 0 || pieces.byteLength % SHA1_HASH_LENGTH !== 0) {
|
|
187
|
+
fail(TorrentParseErrorCode.PIECES_INVALID, 'Invalid pieces field', 'pieces');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const hashes: Uint8Array[] = [];
|
|
191
|
+
|
|
192
|
+
for (let offset = 0; offset < pieces.byteLength; offset += SHA1_HASH_LENGTH) {
|
|
193
|
+
hashes.push(pieces.subarray(offset, offset + SHA1_HASH_LENGTH));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return hashes;
|
|
197
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { bytesToHex } from '@utils/buffers';
|
|
4
|
+
import { toBValue } from '../bencode';
|
|
5
|
+
import type { BDict, BValue } from '../bencode/types';
|
|
6
|
+
import { computeInfoHash } from './info-hash';
|
|
7
|
+
|
|
8
|
+
describe('computeInfoHash', () => {
|
|
9
|
+
test('hashes the canonical bencoded info dictionary', () => {
|
|
10
|
+
const info = toBValue({
|
|
11
|
+
length: 12345,
|
|
12
|
+
name: 'file.bin',
|
|
13
|
+
'piece length': 16384,
|
|
14
|
+
pieces: new Uint8Array([0x00, 0xff, 0x69, 0x65]),
|
|
15
|
+
}) as BDict;
|
|
16
|
+
|
|
17
|
+
const hashed = 'c3982cffb55c2a39ac33477abbd4932316df786c';
|
|
18
|
+
|
|
19
|
+
expect(bytesToHex(computeInfoHash(info))).toBe(hashed);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('is stable regardless of dictionary insertion order', () => {
|
|
23
|
+
const pieces = new Uint8Array([0x00, 0xff, 0x69, 0x65]);
|
|
24
|
+
const first = new Map<string, BValue>([
|
|
25
|
+
['name', new TextEncoder().encode('file.bin')],
|
|
26
|
+
['length', 12345],
|
|
27
|
+
['piece length', 16384],
|
|
28
|
+
['pieces', pieces],
|
|
29
|
+
]) as BDict;
|
|
30
|
+
const second = new Map<string, BValue>([
|
|
31
|
+
['pieces', pieces],
|
|
32
|
+
['piece length', 16384],
|
|
33
|
+
['length', 12345],
|
|
34
|
+
['name', new TextEncoder().encode('file.bin')],
|
|
35
|
+
]) as BDict;
|
|
36
|
+
|
|
37
|
+
expect(bytesToHex(computeInfoHash(first))).toBe(bytesToHex(computeInfoHash(second)));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BunTorrentError } from '@utils/errors';
|
|
2
|
+
|
|
3
|
+
export enum TorrentParseErrorCode {
|
|
4
|
+
ROOT_NOT_DICT = 'TORRENT_ROOT_NOT_DICT',
|
|
5
|
+
INFO_MISSING = 'TORRENT_INFO_MISSING',
|
|
6
|
+
INFO_NOT_DICT = 'TORRENT_INFO_NOT_DICT',
|
|
7
|
+
FIELD_MISSING = 'TORRENT_FIELD_MISSING',
|
|
8
|
+
FIELD_INVALID = 'TORRENT_FIELD_INVALID',
|
|
9
|
+
FILE_PATH_INVALID = 'TORRENT_FILE_PATH_INVALID',
|
|
10
|
+
PIECES_INVALID = 'TORRENT_PIECES_INVALID',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class TorrentParseError extends BunTorrentError {
|
|
14
|
+
constructor(
|
|
15
|
+
code: TorrentParseErrorCode,
|
|
16
|
+
message: string,
|
|
17
|
+
public readonly field?: string,
|
|
18
|
+
) {
|
|
19
|
+
super(message, code);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { BunTorrentError } from '@utils/errors';
|
|
4
|
+
import { bytesToHex } from '@utils/buffers';
|
|
5
|
+
import { encodeBencode, toBValue } from '../bencode';
|
|
6
|
+
import { TorrentParseError, TorrentParseErrorCode } from './parser.error';
|
|
7
|
+
import { parseTorrent } from './index';
|
|
8
|
+
|
|
9
|
+
const textEncoder = new TextEncoder();
|
|
10
|
+
|
|
11
|
+
const bytes = (value: string): Uint8Array => textEncoder.encode(value);
|
|
12
|
+
|
|
13
|
+
const makePieces = (count: number): Uint8Array => {
|
|
14
|
+
const pieces = new Uint8Array(count * 20);
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < pieces.byteLength; i++) {
|
|
17
|
+
pieces[i] = i;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return pieces;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const makeTorrent = ({
|
|
24
|
+
info: infoOverrides = {},
|
|
25
|
+
root: rootOverrides = {},
|
|
26
|
+
}: {
|
|
27
|
+
info?: Record<string, unknown>;
|
|
28
|
+
root?: Record<string, unknown>;
|
|
29
|
+
} = {}): Uint8Array => {
|
|
30
|
+
const info = Object.fromEntries(
|
|
31
|
+
Object.entries({
|
|
32
|
+
length: 12345,
|
|
33
|
+
name: 'file.bin',
|
|
34
|
+
'piece length': 16384,
|
|
35
|
+
pieces: makePieces(2),
|
|
36
|
+
...infoOverrides,
|
|
37
|
+
}).filter(([, value]) => value !== undefined),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return encodeBencode(
|
|
41
|
+
toBValue(
|
|
42
|
+
Object.fromEntries(
|
|
43
|
+
Object.entries({
|
|
44
|
+
announce: 'https://tracker.test/announce',
|
|
45
|
+
info,
|
|
46
|
+
...rootOverrides,
|
|
47
|
+
}).filter(([, value]) => value !== undefined),
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const expectParseError = (input: Uint8Array, code: TorrentParseErrorCode, field?: string): void => {
|
|
54
|
+
try {
|
|
55
|
+
parseTorrent(input);
|
|
56
|
+
throw new Error('Expected parseTorrent to throw');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
expect(error).toBeInstanceOf(TorrentParseError);
|
|
59
|
+
expect(error).toBeInstanceOf(BunTorrentError);
|
|
60
|
+
expect((error as TorrentParseError).code).toBe(code);
|
|
61
|
+
|
|
62
|
+
if (field !== undefined) {
|
|
63
|
+
expect((error as TorrentParseError).field).toBe(field);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe('parseTorrent', () => {
|
|
69
|
+
test('parses single-file torrent metadata', () => {
|
|
70
|
+
const metadata = parseTorrent(makeTorrent());
|
|
71
|
+
|
|
72
|
+
expect(metadata.announce).toBe('https://tracker.test/announce');
|
|
73
|
+
expect(metadata.announceList).toEqual([]);
|
|
74
|
+
expect(metadata.name).toBe('file.bin');
|
|
75
|
+
expect(metadata.length).toBe(12345);
|
|
76
|
+
expect(metadata.pieceLength).toBe(16384);
|
|
77
|
+
expect(metadata.pieces).toHaveLength(2);
|
|
78
|
+
expect([...metadata.pieces[0]!]).toEqual([...makePieces(1)]);
|
|
79
|
+
expect([...metadata.pieces[1]!]).toEqual([...makePieces(2).subarray(20, 40)]);
|
|
80
|
+
expect(metadata.files).toEqual([{ path: ['file.bin'], length: 12345, offset: 0 }]);
|
|
81
|
+
expect(metadata.infoHash.byteLength).toBe(20);
|
|
82
|
+
expect(bytesToHex(metadata.infoHash)).toBe('a6de37ba09e404285c68421a31e3320ae9758501');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('parses multi-file torrent metadata', () => {
|
|
86
|
+
const metadata = parseTorrent(
|
|
87
|
+
makeTorrent({
|
|
88
|
+
info: {
|
|
89
|
+
name: 'folder',
|
|
90
|
+
length: undefined,
|
|
91
|
+
files: [
|
|
92
|
+
{
|
|
93
|
+
length: 100,
|
|
94
|
+
path: ['a.txt'],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
length: 250,
|
|
98
|
+
path: ['nested', 'b.bin'],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(metadata.name).toBe('folder');
|
|
106
|
+
expect(metadata.length).toBe(350);
|
|
107
|
+
expect(metadata.files).toEqual([
|
|
108
|
+
{ path: ['a.txt'], length: 100, offset: 0 },
|
|
109
|
+
{ path: ['nested', 'b.bin'], length: 250, offset: 100 },
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('parses announce-list tiers', () => {
|
|
114
|
+
const metadata = parseTorrent(
|
|
115
|
+
makeTorrent({
|
|
116
|
+
root: {
|
|
117
|
+
'announce-list': [
|
|
118
|
+
['https://tracker-a.test/announce'],
|
|
119
|
+
['https://tracker-b.test/announce', 'https://tracker-c.test/announce'],
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(metadata.announceList).toEqual([
|
|
126
|
+
['https://tracker-a.test/announce'],
|
|
127
|
+
['https://tracker-b.test/announce', 'https://tracker-c.test/announce'],
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('allows torrents without announce', () => {
|
|
132
|
+
const input = encodeBencode(
|
|
133
|
+
toBValue({
|
|
134
|
+
info: {
|
|
135
|
+
length: 12345,
|
|
136
|
+
name: 'file.bin',
|
|
137
|
+
'piece length': 16384,
|
|
138
|
+
pieces: makePieces(1),
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(parseTorrent(input).announce).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('rejects non-dictionary roots', () => {
|
|
147
|
+
expectParseError(encodeBencode(1), TorrentParseErrorCode.ROOT_NOT_DICT, 'root');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('rejects missing info dictionary', () => {
|
|
151
|
+
expectParseError(
|
|
152
|
+
encodeBencode(toBValue({ announce: 'https://tracker.test/announce' })),
|
|
153
|
+
TorrentParseErrorCode.INFO_MISSING,
|
|
154
|
+
'info',
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('rejects non-dictionary info values', () => {
|
|
159
|
+
expectParseError(
|
|
160
|
+
encodeBencode(toBValue({ info: 'file.bin' })),
|
|
161
|
+
TorrentParseErrorCode.INFO_NOT_DICT,
|
|
162
|
+
'info',
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('rejects missing required info fields', () => {
|
|
167
|
+
expectParseError(
|
|
168
|
+
makeTorrent({ info: { name: undefined } }),
|
|
169
|
+
TorrentParseErrorCode.FIELD_MISSING,
|
|
170
|
+
'name',
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('rejects invalid required info field types', () => {
|
|
175
|
+
expectParseError(
|
|
176
|
+
makeTorrent({ info: { length: bytes('12345') } }),
|
|
177
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
178
|
+
'length',
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('rejects pieces values that are not made of 20-byte SHA-1 hashes', () => {
|
|
183
|
+
expectParseError(
|
|
184
|
+
makeTorrent({ info: { pieces: new Uint8Array(21) } }),
|
|
185
|
+
TorrentParseErrorCode.PIECES_INVALID,
|
|
186
|
+
'pieces',
|
|
187
|
+
);
|
|
188
|
+
expectParseError(
|
|
189
|
+
makeTorrent({ info: { pieces: new Uint8Array() } }),
|
|
190
|
+
TorrentParseErrorCode.PIECES_INVALID,
|
|
191
|
+
'pieces',
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('rejects invalid multi-file entries', () => {
|
|
196
|
+
expectParseError(
|
|
197
|
+
makeTorrent({
|
|
198
|
+
info: {
|
|
199
|
+
length: undefined,
|
|
200
|
+
files: ['file.bin'],
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
204
|
+
'files[0]',
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expectParseError(
|
|
208
|
+
makeTorrent({
|
|
209
|
+
info: {
|
|
210
|
+
length: undefined,
|
|
211
|
+
files: [
|
|
212
|
+
{
|
|
213
|
+
path: ['file.bin'],
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
TorrentParseErrorCode.FIELD_MISSING,
|
|
219
|
+
'files[0].length',
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expectParseError(
|
|
223
|
+
makeTorrent({
|
|
224
|
+
info: {
|
|
225
|
+
length: undefined,
|
|
226
|
+
files: [
|
|
227
|
+
{
|
|
228
|
+
length: 123,
|
|
229
|
+
path: [],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
TorrentParseErrorCode.FILE_PATH_INVALID,
|
|
235
|
+
'files[0].path',
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expectParseError(
|
|
239
|
+
makeTorrent({
|
|
240
|
+
info: {
|
|
241
|
+
length: undefined,
|
|
242
|
+
files: [
|
|
243
|
+
{
|
|
244
|
+
length: 123,
|
|
245
|
+
path: ['..'],
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
TorrentParseErrorCode.FILE_PATH_INVALID,
|
|
251
|
+
'files[0].path[0]',
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('rejects invalid announce-list shape', () => {
|
|
256
|
+
expectParseError(
|
|
257
|
+
makeTorrent({
|
|
258
|
+
root: {
|
|
259
|
+
'announce-list': 'https://tracker.test/announce',
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
263
|
+
'announce-list',
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expectParseError(
|
|
267
|
+
makeTorrent({
|
|
268
|
+
root: {
|
|
269
|
+
'announce-list': ['https://tracker.test/announce'],
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
273
|
+
'announce-list[0]',
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expectParseError(
|
|
277
|
+
makeTorrent({
|
|
278
|
+
root: {
|
|
279
|
+
'announce-list': [[123]],
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
TorrentParseErrorCode.FIELD_INVALID,
|
|
283
|
+
'announce-list[0][0]',
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
});
|