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,212 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { decodeBencode } from '@torrent/bencode';
|
|
4
|
+
import { BunTorrentError } from '@utils/errors';
|
|
5
|
+
import { BencodeDecodeError, BencodeDecodeErrorCode } from '../decoder.error';
|
|
6
|
+
|
|
7
|
+
const bytes = (value: string): Uint8Array => new TextEncoder().encode(value);
|
|
8
|
+
|
|
9
|
+
const expectDecodeError = (value: string, code: BencodeDecodeErrorCode, offset?: number): void => {
|
|
10
|
+
try {
|
|
11
|
+
decodeBencode(bytes(value));
|
|
12
|
+
throw new Error('Expected decodeBencode to throw');
|
|
13
|
+
} catch (error) {
|
|
14
|
+
expect(error).toBeInstanceOf(BencodeDecodeError);
|
|
15
|
+
expect(error).toBeInstanceOf(BunTorrentError);
|
|
16
|
+
expect((error as BencodeDecodeError).code).toBe(code);
|
|
17
|
+
|
|
18
|
+
if (offset !== undefined) {
|
|
19
|
+
expect((error as BencodeDecodeError).offset).toBe(offset);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const expectDecodedBytes = (value: string, expected: string): void => {
|
|
25
|
+
const decoded = decodeBencode(bytes(value));
|
|
26
|
+
|
|
27
|
+
expect(decoded).toBeInstanceOf(Uint8Array);
|
|
28
|
+
expect([...Uint8Array.from(decoded as Uint8Array)]).toEqual([...bytes(expected)]);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const expectBytesValue = (value: unknown, expected: string): void => {
|
|
32
|
+
expect(value).toBeInstanceOf(Uint8Array);
|
|
33
|
+
expect([...Uint8Array.from(value as Uint8Array)]).toEqual([...bytes(expected)]);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe('decodeBencode integer', () => {
|
|
37
|
+
test('decodes zero', () => {
|
|
38
|
+
expect(decodeBencode(bytes('i0e'))).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('decodes positive integers', () => {
|
|
42
|
+
expect(decodeBencode(bytes('i1e'))).toBe(1);
|
|
43
|
+
expect(decodeBencode(bytes('i42e'))).toBe(42);
|
|
44
|
+
expect(decodeBencode(bytes('i123456789e'))).toBe(123456789);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('decodes negative integers', () => {
|
|
48
|
+
expect(decodeBencode(bytes('i-1e'))).toBe(-1);
|
|
49
|
+
expect(decodeBencode(bytes('i-42e'))).toBe(-42);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('rejects missing integer digits', () => {
|
|
53
|
+
expectDecodeError('ie', BencodeDecodeErrorCode.MISSING_INTEGER_DIGITS, 1);
|
|
54
|
+
expectDecodeError('i-e', BencodeDecodeErrorCode.MISSING_INTEGER_DIGITS, 2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('rejects negative zero', () => {
|
|
58
|
+
expectDecodeError('i-0e', BencodeDecodeErrorCode.NEGATIVE_ZERO, 4);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('rejects leading zeroes', () => {
|
|
62
|
+
expectDecodeError('i03e', BencodeDecodeErrorCode.LEADING_ZERO, 1);
|
|
63
|
+
expectDecodeError('i042e', BencodeDecodeErrorCode.LEADING_ZERO, 1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('rejects unterminated integers', () => {
|
|
67
|
+
expectDecodeError('i42', BencodeDecodeErrorCode.UNTERMINATED_INTEGER, 3);
|
|
68
|
+
expectDecodeError('i-42', BencodeDecodeErrorCode.UNTERMINATED_INTEGER, 4);
|
|
69
|
+
expectDecodeError('i0', BencodeDecodeErrorCode.UNTERMINATED_INTEGER, 2);
|
|
70
|
+
expectDecodeError('i-0', BencodeDecodeErrorCode.UNTERMINATED_INTEGER, 3);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('rejects integers outside the JavaScript safe integer range', () => {
|
|
74
|
+
expectDecodeError('i9007199254740992e', BencodeDecodeErrorCode.UNSAFE_INTEGER, 18);
|
|
75
|
+
expectDecodeError('i-9007199254740992e', BencodeDecodeErrorCode.UNSAFE_INTEGER, 19);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('rejects trailing data after a root integer', () => {
|
|
79
|
+
expectDecodeError('i42ee', BencodeDecodeErrorCode.TRAILING_DATA, 4);
|
|
80
|
+
expectDecodeError('i42e0:', BencodeDecodeErrorCode.TRAILING_DATA, 4);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('rejects unsupported root values with a bencode decode error', () => {
|
|
84
|
+
expectDecodeError('x', BencodeDecodeErrorCode.BAD_FORMAT, 0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('decodeBencode bytes', () => {
|
|
89
|
+
test('decodes empty bytes', () => {
|
|
90
|
+
const decoded = decodeBencode(bytes('0:'));
|
|
91
|
+
|
|
92
|
+
expect(decoded).toBeInstanceOf(Uint8Array);
|
|
93
|
+
expect((decoded as Uint8Array).byteLength).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('decodes byte strings', () => {
|
|
97
|
+
expectDecodedBytes('4:spam', 'spam');
|
|
98
|
+
expectDecodedBytes('11:hello world', 'hello world');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('preserves raw binary bytes', () => {
|
|
102
|
+
const input = new Uint8Array([0x34, 0x3a, 0x00, 0xff, 0x69, 0x65]);
|
|
103
|
+
const decoded = decodeBencode(input);
|
|
104
|
+
|
|
105
|
+
expect(decoded).toBeInstanceOf(Uint8Array);
|
|
106
|
+
expect([...Uint8Array.from(decoded as Uint8Array)]).toEqual([0x00, 0xff, 0x69, 0x65]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('rejects leading zeroes in byte string length', () => {
|
|
110
|
+
expectDecodeError('04:spam', BencodeDecodeErrorCode.LEADING_ZERO, 0);
|
|
111
|
+
expectDecodeError('00:', BencodeDecodeErrorCode.LEADING_ZERO, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('rejects byte strings without a delimiter', () => {
|
|
115
|
+
expectDecodeError('4spam', BencodeDecodeErrorCode.EXPECTED_DELIM, 1);
|
|
116
|
+
expectDecodeError('4', BencodeDecodeErrorCode.EXPECTED_DELIM, 1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('rejects truncated byte strings', () => {
|
|
120
|
+
expectDecodeError('4:spa', BencodeDecodeErrorCode.BUFFER_OVERFLOW, 2);
|
|
121
|
+
expectDecodeError('1:', BencodeDecodeErrorCode.BUFFER_OVERFLOW, 2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('rejects trailing data after root bytes', () => {
|
|
125
|
+
expectDecodeError('4:spame', BencodeDecodeErrorCode.TRAILING_DATA, 6);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('decodeBencode list', () => {
|
|
130
|
+
test('decodes empty lists', () => {
|
|
131
|
+
expect(decodeBencode(bytes('le'))).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('decodes lists with mixed values', () => {
|
|
135
|
+
const decoded = decodeBencode(bytes('l4:spami42ee'));
|
|
136
|
+
|
|
137
|
+
expect(Array.isArray(decoded)).toBe(true);
|
|
138
|
+
|
|
139
|
+
const list = decoded as unknown[];
|
|
140
|
+
expectBytesValue(list[0], 'spam');
|
|
141
|
+
expect(list[1]).toBe(42);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('decodes nested lists', () => {
|
|
145
|
+
const decoded = decodeBencode(bytes('lli1ei2ee3:fooe'));
|
|
146
|
+
|
|
147
|
+
expect(Array.isArray(decoded)).toBe(true);
|
|
148
|
+
|
|
149
|
+
const list = decoded as unknown[];
|
|
150
|
+
expect(list[0]).toEqual([1, 2]);
|
|
151
|
+
expectBytesValue(list[1], 'foo');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('rejects unterminated lists', () => {
|
|
155
|
+
expectDecodeError('l4:spam', BencodeDecodeErrorCode.UNTERMINATED_LIST, 7);
|
|
156
|
+
expectDecodeError('li1e', BencodeDecodeErrorCode.UNTERMINATED_LIST, 4);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('rejects trailing data after root lists', () => {
|
|
160
|
+
expectDecodeError('lee', BencodeDecodeErrorCode.TRAILING_DATA, 2);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('decodeBencode dictionary', () => {
|
|
165
|
+
test('decodes empty dictionaries', () => {
|
|
166
|
+
const decoded = decodeBencode(bytes('de'));
|
|
167
|
+
|
|
168
|
+
expect(decoded).toBeInstanceOf(Map);
|
|
169
|
+
expect((decoded as Map<string, unknown>).size).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('decodes dictionaries with mixed values', () => {
|
|
173
|
+
const decoded = decodeBencode(bytes('d3:foo3:bar3:numi42ee'));
|
|
174
|
+
|
|
175
|
+
expect(decoded).toBeInstanceOf(Map);
|
|
176
|
+
|
|
177
|
+
const dict = decoded as Map<string, unknown>;
|
|
178
|
+
expectBytesValue(dict.get('foo'), 'bar');
|
|
179
|
+
expect(dict.get('num')).toBe(42);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('decodes nested dictionaries and lists', () => {
|
|
183
|
+
const decoded = decodeBencode(bytes('d4:dictd3:key5:valuee4:listli1e3:twoee'));
|
|
184
|
+
|
|
185
|
+
expect(decoded).toBeInstanceOf(Map);
|
|
186
|
+
|
|
187
|
+
const dict = decoded as Map<string, unknown>;
|
|
188
|
+
const nested = dict.get('dict') as Map<string, unknown>;
|
|
189
|
+
const list = dict.get('list') as unknown[];
|
|
190
|
+
|
|
191
|
+
expect(nested).toBeInstanceOf(Map);
|
|
192
|
+
expectBytesValue(nested.get('key'), 'value');
|
|
193
|
+
expect(list[0]).toBe(1);
|
|
194
|
+
expectBytesValue(list[1], 'two');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('rejects duplicate keys', () => {
|
|
198
|
+
expectDecodeError('d3:fooi1e3:fooi2ee', BencodeDecodeErrorCode.DUPLICATE_KEY, 14);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('rejects non-byte dictionary keys', () => {
|
|
202
|
+
expectDecodeError('di1e3:bare', BencodeDecodeErrorCode.EXPECTED_DIGIT, 1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('rejects unterminated dictionaries', () => {
|
|
206
|
+
expectDecodeError('d3:foo3:bar', BencodeDecodeErrorCode.UNTERMINATED_DICT, 11);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('rejects trailing data after root dictionaries', () => {
|
|
210
|
+
expectDecodeError('dee', BencodeDecodeErrorCode.TRAILING_DATA, 2);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { encodeBencode } from '@torrent/bencode';
|
|
4
|
+
import { BunTorrentError } from '@utils/errors';
|
|
5
|
+
import { BencodeEncodeError, BencodeEncodeErrorCode } from '../encoder.error';
|
|
6
|
+
import type { BValue } from '../types';
|
|
7
|
+
|
|
8
|
+
const textEncoder = new TextEncoder();
|
|
9
|
+
const textDecoder = new TextDecoder();
|
|
10
|
+
|
|
11
|
+
const bytes = (value: string): Uint8Array => textEncoder.encode(value);
|
|
12
|
+
const text = (value: Uint8Array): string => textDecoder.decode(value);
|
|
13
|
+
|
|
14
|
+
const expectEncoded = (value: Parameters<typeof encodeBencode>[0], expected: string): void => {
|
|
15
|
+
expect(text(encodeBencode(value))).toBe(expected);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const expectEncodeError = (value: unknown, code: BencodeEncodeErrorCode): void => {
|
|
19
|
+
try {
|
|
20
|
+
encodeBencode(value as BValue);
|
|
21
|
+
throw new Error('Expected encodeBencode to throw');
|
|
22
|
+
} catch (error) {
|
|
23
|
+
expect(error).toBeInstanceOf(BencodeEncodeError);
|
|
24
|
+
expect(error).toBeInstanceOf(BunTorrentError);
|
|
25
|
+
expect((error as BencodeEncodeError).code).toBe(code);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('encodeBencode integer', () => {
|
|
30
|
+
test('encodes zero', () => {
|
|
31
|
+
expectEncoded(0, 'i0e');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('encodes positive integers', () => {
|
|
35
|
+
expectEncoded(1, 'i1e');
|
|
36
|
+
expectEncoded(42, 'i42e');
|
|
37
|
+
expectEncoded(123456789, 'i123456789e');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('encodes negative integers', () => {
|
|
41
|
+
expectEncoded(-1, 'i-1e');
|
|
42
|
+
expectEncoded(-42, 'i-42e');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('rejects negative zero', () => {
|
|
46
|
+
expectEncodeError(-0, BencodeEncodeErrorCode.NEGATIVE_ZERO);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('rejects unsafe integers', () => {
|
|
50
|
+
expectEncodeError(Number.MAX_SAFE_INTEGER + 1, BencodeEncodeErrorCode.UNSAFE_INTEGER);
|
|
51
|
+
expectEncodeError(Number.MIN_SAFE_INTEGER - 1, BencodeEncodeErrorCode.UNSAFE_INTEGER);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('rejects invalid integer numbers', () => {
|
|
55
|
+
expectEncodeError(1.5, BencodeEncodeErrorCode.INVALID_INTEGER);
|
|
56
|
+
expectEncodeError(Number.NaN, BencodeEncodeErrorCode.INVALID_INTEGER);
|
|
57
|
+
expectEncodeError(Number.POSITIVE_INFINITY, BencodeEncodeErrorCode.INVALID_INTEGER);
|
|
58
|
+
expectEncodeError(Number.NEGATIVE_INFINITY, BencodeEncodeErrorCode.INVALID_INTEGER);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('encodeBencode bytes', () => {
|
|
63
|
+
test('encodes empty bytes', () => {
|
|
64
|
+
expectEncoded(new Uint8Array(), '0:');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('encodes byte strings', () => {
|
|
68
|
+
expectEncoded(bytes('spam'), '4:spam');
|
|
69
|
+
expectEncoded(bytes('hello world'), '11:hello world');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('preserves raw binary bytes', () => {
|
|
73
|
+
const encoded = encodeBencode(new Uint8Array([0x00, 0xff, 0x69, 0x65]));
|
|
74
|
+
|
|
75
|
+
expect([...encoded]).toEqual([0x34, 0x3a, 0x00, 0xff, 0x69, 0x65]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('encodeBencode unsupported values', () => {
|
|
80
|
+
test('rejects unsupported runtime values', () => {
|
|
81
|
+
expectEncodeError('spam', BencodeEncodeErrorCode.UNSUPPORTED_VALUE);
|
|
82
|
+
expectEncodeError(null, BencodeEncodeErrorCode.UNSUPPORTED_VALUE);
|
|
83
|
+
expectEncodeError(undefined, BencodeEncodeErrorCode.UNSUPPORTED_VALUE);
|
|
84
|
+
expectEncodeError({ value: 1 }, BencodeEncodeErrorCode.UNSUPPORTED_VALUE);
|
|
85
|
+
expectEncodeError(true, BencodeEncodeErrorCode.UNSUPPORTED_VALUE);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('encodeBencode list', () => {
|
|
90
|
+
test('encodes empty lists', () => {
|
|
91
|
+
expectEncoded([], 'le');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('encodes lists with mixed values', () => {
|
|
95
|
+
expectEncoded([bytes('spam'), 42], 'l4:spami42ee');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('encodes nested lists', () => {
|
|
99
|
+
expectEncoded([[1, 2], bytes('foo')], 'lli1ei2ee3:fooe');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('encodeBencode dictionary', () => {
|
|
104
|
+
test('encodes empty dictionaries', () => {
|
|
105
|
+
expectEncoded(new Map(), 'de');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('encodes dictionaries with mixed values', () => {
|
|
109
|
+
expectEncoded(
|
|
110
|
+
new Map<string, BValue>([
|
|
111
|
+
['foo', bytes('bar')],
|
|
112
|
+
['num', 42],
|
|
113
|
+
]),
|
|
114
|
+
'd3:foo3:bar3:numi42ee',
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('sorts dictionary keys by byte order', () => {
|
|
119
|
+
expectEncoded(
|
|
120
|
+
new Map<string, BValue>([
|
|
121
|
+
['spam', bytes('eggs')],
|
|
122
|
+
['bar', bytes('baz')],
|
|
123
|
+
['foo', 42],
|
|
124
|
+
]),
|
|
125
|
+
'd3:bar3:baz3:fooi42e4:spam4:eggse',
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('encodes nested dictionaries and lists', () => {
|
|
130
|
+
expectEncoded(
|
|
131
|
+
new Map<string, BValue>([
|
|
132
|
+
['dict', new Map<string, BValue>([['key', bytes('value')]])],
|
|
133
|
+
['list', [1, bytes('two')]],
|
|
134
|
+
]),
|
|
135
|
+
'd4:dictd3:key5:valuee4:listli1e3:twoee',
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { encodeBencode, toBValue } from '@torrent/bencode';
|
|
4
|
+
|
|
5
|
+
const textDecoder = new TextDecoder();
|
|
6
|
+
|
|
7
|
+
const text = (value: Uint8Array): string => textDecoder.decode(value);
|
|
8
|
+
|
|
9
|
+
describe('encodeBencode happy path', () => {
|
|
10
|
+
test('encodes a torrent-shaped metadata dictionary from convenience input', () => {
|
|
11
|
+
const value = toBValue({
|
|
12
|
+
announce: 'https://tracker.test/announce',
|
|
13
|
+
info: {
|
|
14
|
+
length: 12345,
|
|
15
|
+
name: 'file.bin',
|
|
16
|
+
'piece length': 16384,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(text(encodeBencode(value))).toBe(
|
|
21
|
+
'd8:announce29:https://tracker.test/announce4:infod6:lengthi12345e4:name8:file.bin12:piece lengthi16384eee',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { decodeBencode, encodeBencode, toBValue } from '@torrent/bencode';
|
|
4
|
+
|
|
5
|
+
const textDecoder = new TextDecoder();
|
|
6
|
+
|
|
7
|
+
const expectBytesText = (value: unknown, expected: string): void => {
|
|
8
|
+
expect(value).toBeInstanceOf(Uint8Array);
|
|
9
|
+
expect(textDecoder.decode(value as Uint8Array)).toBe(expected);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('bencode integration', () => {
|
|
13
|
+
test('round trips empty containers', () => {
|
|
14
|
+
const decoded = decodeBencode(encodeBencode(toBValue({ emptyDict: {}, emptyList: [] })));
|
|
15
|
+
|
|
16
|
+
expect(decoded).toBeInstanceOf(Map);
|
|
17
|
+
|
|
18
|
+
const root = decoded as Map<string, unknown>;
|
|
19
|
+
expect(root.get('emptyDict')).toBeInstanceOf(Map);
|
|
20
|
+
expect((root.get('emptyDict') as Map<string, unknown>).size).toBe(0);
|
|
21
|
+
expect(root.get('emptyList')).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('round trips a torrent-shaped value with every bencode type', () => {
|
|
25
|
+
const pieces = new Uint8Array(40);
|
|
26
|
+
for (let i = 0; i < pieces.byteLength; i++) {
|
|
27
|
+
pieces[i] = i;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const value = toBValue({
|
|
31
|
+
announce: 'https://tracker.test/announce',
|
|
32
|
+
'announce-list': [
|
|
33
|
+
['https://tracker-a.test/announce'],
|
|
34
|
+
['https://tracker-b.test/announce'],
|
|
35
|
+
],
|
|
36
|
+
info: {
|
|
37
|
+
length: 12345,
|
|
38
|
+
name: 'file.bin',
|
|
39
|
+
'piece length': 16384,
|
|
40
|
+
pieces,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const decoded = decodeBencode(encodeBencode(value));
|
|
45
|
+
|
|
46
|
+
expect(decoded).toBeInstanceOf(Map);
|
|
47
|
+
|
|
48
|
+
const root = decoded as Map<string, unknown>;
|
|
49
|
+
expectBytesText(root.get('announce'), 'https://tracker.test/announce');
|
|
50
|
+
|
|
51
|
+
const announceList = root.get('announce-list') as unknown[];
|
|
52
|
+
expect(Array.isArray(announceList)).toBe(true);
|
|
53
|
+
expectBytesText((announceList[0] as unknown[])[0], 'https://tracker-a.test/announce');
|
|
54
|
+
expectBytesText((announceList[1] as unknown[])[0], 'https://tracker-b.test/announce');
|
|
55
|
+
|
|
56
|
+
const info = root.get('info') as Map<string, unknown>;
|
|
57
|
+
expect(info).toBeInstanceOf(Map);
|
|
58
|
+
expect(info.get('length')).toBe(12345);
|
|
59
|
+
expectBytesText(info.get('name'), 'file.bin');
|
|
60
|
+
expect(info.get('piece length')).toBe(16384);
|
|
61
|
+
expect(info.get('pieces')).toBeInstanceOf(Uint8Array);
|
|
62
|
+
expect([...Uint8Array.from(info.get('pieces') as Uint8Array)]).toEqual([...pieces]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BunTorrentError } from '@utils/errors';
|
|
2
|
+
|
|
3
|
+
export enum BencodeDecodeErrorCode {
|
|
4
|
+
// Global
|
|
5
|
+
BAD_FORMAT = 'BENCODE_BAD_FORMAT',
|
|
6
|
+
BUFFER_OVERFLOW = 'BENCODE_BUFFER_OVERFLOW',
|
|
7
|
+
EXPECTED_DELIM = 'BENCODE_EXPECTED_DELIM',
|
|
8
|
+
TRAILING_DATA = 'BENCODE_TRAILING_DATA',
|
|
9
|
+
UNSAFE_INTEGER = 'BENCODE_UNSAFE_INTEGER',
|
|
10
|
+
// Integer
|
|
11
|
+
EXPECTED_INTEGER = 'BENCODE_EXPECTED_INTEGER',
|
|
12
|
+
MISSING_INTEGER_DIGITS = 'BENCODE_MISSING_INTEGER_DIGITS',
|
|
13
|
+
LEADING_ZERO = 'BENCODE_LEADING_ZERO',
|
|
14
|
+
NEGATIVE_ZERO = 'BENCODE_NEGATIVE_ZERO',
|
|
15
|
+
UNTERMINATED_INTEGER = 'BENCODE_UNTERMINATED_INTEGER',
|
|
16
|
+
// Bytes
|
|
17
|
+
EXPECTED_DIGIT = 'BENCODE_EXPECTED_DIGIT',
|
|
18
|
+
MAX_SIZE_EXCEEDED = 'BENCODE_MAX_SIZE_EXCEEDED',
|
|
19
|
+
// List
|
|
20
|
+
EXPECTED_LIST_FLAG = 'BENCODE_EXPECTED_LIST_FLAG',
|
|
21
|
+
UNTERMINATED_LIST = 'BENCODE_UNTERMINATED_LIST',
|
|
22
|
+
// Dict
|
|
23
|
+
EXPECTED_DICT_FLAG = 'BENCODE_EXPECTED_DICT_FLAG',
|
|
24
|
+
DUPLICATE_KEY = 'BENCODE_DUPLICATE_DICT_KEY',
|
|
25
|
+
UNTERMINATED_DICT = 'BENCODE_UNTERMINATED_DICT',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class BencodeDecodeError extends BunTorrentError {
|
|
29
|
+
constructor(
|
|
30
|
+
code: BencodeDecodeErrorCode,
|
|
31
|
+
message: string,
|
|
32
|
+
public readonly offset: number,
|
|
33
|
+
) {
|
|
34
|
+
super(message, code);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { BencodeDecodeError, BencodeDecodeErrorCode } from './decoder.error';
|
|
2
|
+
import { FLAG, type BDict, type BInteger, type BValue } from './types';
|
|
3
|
+
|
|
4
|
+
export const decodeBencode = (input: Uint8Array): BValue => {
|
|
5
|
+
const decoder = createDecoder({ input });
|
|
6
|
+
return decoder.decode();
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const createDecoder = ({
|
|
10
|
+
input,
|
|
11
|
+
strict = true,
|
|
12
|
+
maxBytesLength = 1024 * 1024,
|
|
13
|
+
}: {
|
|
14
|
+
input: Uint8Array;
|
|
15
|
+
strict?: boolean;
|
|
16
|
+
maxBytesLength?: number;
|
|
17
|
+
}) => {
|
|
18
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: strict });
|
|
19
|
+
const buffer = input;
|
|
20
|
+
let offset = 0;
|
|
21
|
+
|
|
22
|
+
const hasRemainingData = (): boolean => offset < buffer.length;
|
|
23
|
+
const hasNextChar = (): boolean => offset + 1 < buffer.length;
|
|
24
|
+
|
|
25
|
+
const char = (): number => {
|
|
26
|
+
if (!hasRemainingData()) fail(BencodeDecodeErrorCode.BUFFER_OVERFLOW, 'Buffer overflow');
|
|
27
|
+
return buffer[offset]!;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const nextChar = (): number => {
|
|
31
|
+
if (!hasNextChar()) fail(BencodeDecodeErrorCode.BUFFER_OVERFLOW, 'Buffer overflow');
|
|
32
|
+
return buffer[offset + 1]!;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const readNumber = (): number => {
|
|
36
|
+
if (char() === 0x30 && hasNextChar() && isDigit(nextChar()))
|
|
37
|
+
fail(BencodeDecodeErrorCode.LEADING_ZERO, 'Leading zeroes not allowed');
|
|
38
|
+
|
|
39
|
+
let integer = 0;
|
|
40
|
+
while (hasRemainingData() && isDigit(char())) {
|
|
41
|
+
const digit = char() - 0x30;
|
|
42
|
+
integer = integer * 10 + digit;
|
|
43
|
+
offset++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return integer;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const checkNumber = (integer: number): void | never => {
|
|
50
|
+
if (Number.isSafeInteger(integer)) return;
|
|
51
|
+
return fail(BencodeDecodeErrorCode.UNSAFE_INTEGER, 'Decoded Integer is not safe');
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const readInteger = (): BInteger => {
|
|
55
|
+
if (char() !== FLAG.INTEGER)
|
|
56
|
+
fail(BencodeDecodeErrorCode.EXPECTED_INTEGER, 'Not bencode Integer');
|
|
57
|
+
offset++;
|
|
58
|
+
|
|
59
|
+
const sign = char() === FLAG.MINUS ? -1 : 1;
|
|
60
|
+
if (sign === -1) offset++;
|
|
61
|
+
|
|
62
|
+
if (!isDigit(char()))
|
|
63
|
+
fail(BencodeDecodeErrorCode.MISSING_INTEGER_DIGITS, 'Missing Integer digits');
|
|
64
|
+
|
|
65
|
+
const integer = readNumber();
|
|
66
|
+
|
|
67
|
+
if (!hasRemainingData() || char() !== FLAG.END)
|
|
68
|
+
fail(
|
|
69
|
+
BencodeDecodeErrorCode.UNTERMINATED_INTEGER,
|
|
70
|
+
"Bencode Integer did not end with 'e'",
|
|
71
|
+
);
|
|
72
|
+
offset++;
|
|
73
|
+
|
|
74
|
+
if (sign === -1 && integer === 0)
|
|
75
|
+
fail(BencodeDecodeErrorCode.NEGATIVE_ZERO, 'Bencode Integer cannot be -0');
|
|
76
|
+
|
|
77
|
+
const result = integer * sign;
|
|
78
|
+
checkNumber(result);
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const readBytes = (): Uint8Array => {
|
|
84
|
+
if (!isDigit(char()))
|
|
85
|
+
fail(BencodeDecodeErrorCode.EXPECTED_DIGIT, 'Not digit, failed to read length');
|
|
86
|
+
|
|
87
|
+
const length = readNumber();
|
|
88
|
+
checkNumber(length);
|
|
89
|
+
|
|
90
|
+
if (length > maxBytesLength)
|
|
91
|
+
fail(
|
|
92
|
+
BencodeDecodeErrorCode.MAX_SIZE_EXCEEDED,
|
|
93
|
+
`Bytes max size exceeded (${length}:${maxBytesLength})`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (!hasRemainingData() || char() !== FLAG.STR_DELIMITER)
|
|
97
|
+
fail(BencodeDecodeErrorCode.EXPECTED_DELIM, 'Bytes failed, expected delimiter');
|
|
98
|
+
offset++;
|
|
99
|
+
|
|
100
|
+
const start = offset,
|
|
101
|
+
end = offset + length;
|
|
102
|
+
if (end > buffer.length) fail(BencodeDecodeErrorCode.BUFFER_OVERFLOW, 'Buffer overflow');
|
|
103
|
+
|
|
104
|
+
offset = end;
|
|
105
|
+
return buffer.subarray(start, end);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const readList = (): Array<BValue> => {
|
|
109
|
+
if (char() !== FLAG.LIST)
|
|
110
|
+
fail(BencodeDecodeErrorCode.EXPECTED_LIST_FLAG, 'Not bencode list flag');
|
|
111
|
+
offset++;
|
|
112
|
+
|
|
113
|
+
const list: BValue[] = [];
|
|
114
|
+
while (hasRemainingData()) {
|
|
115
|
+
if (char() === FLAG.END) {
|
|
116
|
+
offset++;
|
|
117
|
+
return list;
|
|
118
|
+
}
|
|
119
|
+
list.push(readValue());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return fail(BencodeDecodeErrorCode.UNTERMINATED_LIST, "Bencode list did not end with 'e'");
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const readDictionary = (): BDict => {
|
|
126
|
+
if (char() !== FLAG.DICTIONARY)
|
|
127
|
+
fail(BencodeDecodeErrorCode.EXPECTED_DICT_FLAG, 'Not bencode dictionary flag');
|
|
128
|
+
offset++;
|
|
129
|
+
|
|
130
|
+
const dict: BDict = new Map<string, BValue>();
|
|
131
|
+
|
|
132
|
+
while (hasRemainingData()) {
|
|
133
|
+
if (char() === FLAG.END) {
|
|
134
|
+
offset++;
|
|
135
|
+
return dict;
|
|
136
|
+
}
|
|
137
|
+
const key = textDecoder.decode(readBytes());
|
|
138
|
+
if (dict.has(key))
|
|
139
|
+
fail(BencodeDecodeErrorCode.DUPLICATE_KEY, 'Duplicate bencode dictionary key');
|
|
140
|
+
|
|
141
|
+
const value = readValue();
|
|
142
|
+
dict.set(key, value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return fail(
|
|
146
|
+
BencodeDecodeErrorCode.UNTERMINATED_DICT,
|
|
147
|
+
"Bencode dictionary did not end with 'e'",
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const readValue = (): BValue => {
|
|
152
|
+
const current = char();
|
|
153
|
+
|
|
154
|
+
if (current === FLAG.INTEGER) return readInteger();
|
|
155
|
+
if (isDigit(current)) return readBytes();
|
|
156
|
+
if (current === FLAG.LIST) return readList();
|
|
157
|
+
if (current === FLAG.DICTIONARY) return readDictionary();
|
|
158
|
+
|
|
159
|
+
return fail(BencodeDecodeErrorCode.BAD_FORMAT, 'Bad bencode format, failed to decode');
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const decode = (): BValue => {
|
|
163
|
+
const value = readValue();
|
|
164
|
+
|
|
165
|
+
if (hasRemainingData())
|
|
166
|
+
return fail(BencodeDecodeErrorCode.TRAILING_DATA, 'There is excess trailing data');
|
|
167
|
+
|
|
168
|
+
return value;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const fail = (code: BencodeDecodeErrorCode, message: string): never => {
|
|
172
|
+
throw new BencodeDecodeError(code, message, offset);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
decode,
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const isDigit = (char: number) => char >= 0x30 && char <= 0x39;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BunTorrentError } from '@utils/errors';
|
|
2
|
+
|
|
3
|
+
export enum BencodeEncodeErrorCode {
|
|
4
|
+
INVALID_INTEGER = 'BENCODE_ENCODE_INVALID_INTEGER',
|
|
5
|
+
NEGATIVE_ZERO = 'BENCODE_ENCODE_NEGATIVE_ZERO',
|
|
6
|
+
UNSAFE_INTEGER = 'BENCODE_ENCODE_UNSAFE_INTEGER',
|
|
7
|
+
UNSUPPORTED_VALUE = 'BENCODE_ENCODE_UNSUPPORTED_VALUE',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class BencodeEncodeError extends BunTorrentError {
|
|
11
|
+
constructor(code: BencodeEncodeErrorCode, message: string) {
|
|
12
|
+
super(message, code);
|
|
13
|
+
}
|
|
14
|
+
}
|