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,161 @@
|
|
|
1
|
+
import type { TorrentMetadata } from '@torrent/types';
|
|
2
|
+
import { mkdir, open } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
getPieceLength,
|
|
6
|
+
validatePiece,
|
|
7
|
+
type PieceCompletion,
|
|
8
|
+
type PieceValidationResult,
|
|
9
|
+
} from '../pieces';
|
|
10
|
+
import { getTorrentFilePathKey, normalizeTorrentFileSelection } from '../file-selection';
|
|
11
|
+
import { TorrentStorageError, TorrentStorageErrorCode } from './storage.error';
|
|
12
|
+
import type { FileWrite, WritePieceOptions } from './types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Map a completed piece to file write ranges in torrent file order.
|
|
16
|
+
*
|
|
17
|
+
* @param metadata - Parsed torrent metadata.
|
|
18
|
+
* @param pieceIndex - Completed piece index.
|
|
19
|
+
* @param data - Completed piece bytes.
|
|
20
|
+
* @returns File write ranges that cover the piece data.
|
|
21
|
+
* @throws {TorrentStorageError} When the piece index or piece length is invalid.
|
|
22
|
+
*/
|
|
23
|
+
export const planPieceWrites = (
|
|
24
|
+
metadata: TorrentMetadata,
|
|
25
|
+
pieceIndex: number,
|
|
26
|
+
data: Uint8Array,
|
|
27
|
+
): FileWrite[] => {
|
|
28
|
+
const pieceLength = getStoragePieceLength(metadata, pieceIndex);
|
|
29
|
+
if (data.byteLength !== pieceLength) {
|
|
30
|
+
throw new TorrentStorageError(
|
|
31
|
+
TorrentStorageErrorCode.INVALID_PIECE_LENGTH,
|
|
32
|
+
`Piece ${pieceIndex} length mismatch: expected ${pieceLength}, received ${data.byteLength}`,
|
|
33
|
+
pieceIndex,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pieceStart = pieceIndex * metadata.pieceLength;
|
|
38
|
+
const pieceEnd = pieceStart + data.byteLength;
|
|
39
|
+
const writes: FileWrite[] = [];
|
|
40
|
+
|
|
41
|
+
for (const file of metadata.files) {
|
|
42
|
+
const fileStart = file.offset;
|
|
43
|
+
const fileEnd = file.offset + file.length;
|
|
44
|
+
const start = Math.max(pieceStart, fileStart);
|
|
45
|
+
const end = Math.min(pieceEnd, fileEnd);
|
|
46
|
+
|
|
47
|
+
if (start >= end) continue;
|
|
48
|
+
|
|
49
|
+
writes.push({
|
|
50
|
+
path: file.path,
|
|
51
|
+
fileOffset: start - fileStart,
|
|
52
|
+
dataOffset: start - pieceStart,
|
|
53
|
+
length: end - start,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const plannedLength = writes.reduce((total, write) => total + write.length, 0);
|
|
58
|
+
if (plannedLength !== data.byteLength) {
|
|
59
|
+
throw new TorrentStorageError(
|
|
60
|
+
TorrentStorageErrorCode.INVALID_FILE_LAYOUT,
|
|
61
|
+
`Torrent file layout does not cover piece ${pieceIndex}`,
|
|
62
|
+
pieceIndex,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return writes;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Write a validated completed piece into its torrent files.
|
|
71
|
+
*
|
|
72
|
+
* @param metadata - Parsed torrent metadata.
|
|
73
|
+
* @param pieceIndex - Completed piece index.
|
|
74
|
+
* @param data - Validated piece bytes.
|
|
75
|
+
* @param options - Storage options including output directory.
|
|
76
|
+
* @throws {TorrentStorageError} When the piece or target file path is invalid.
|
|
77
|
+
*/
|
|
78
|
+
export const writePiece = async (
|
|
79
|
+
metadata: TorrentMetadata,
|
|
80
|
+
pieceIndex: number,
|
|
81
|
+
data: Uint8Array,
|
|
82
|
+
options: WritePieceOptions,
|
|
83
|
+
): Promise<void> => {
|
|
84
|
+
const selectedFiles = normalizeTorrentFileSelection(options.files);
|
|
85
|
+
const writes = planPieceWrites(metadata, pieceIndex, data).filter(
|
|
86
|
+
(write) => !selectedFiles || selectedFiles.has(getTorrentFilePathKey(write.path)),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
for (const write of writes) {
|
|
90
|
+
const path = resolveTorrentFilePath(options.outputDirectory, write.path);
|
|
91
|
+
await mkdir(dirname(path), { recursive: true });
|
|
92
|
+
|
|
93
|
+
const file = await open(path, 'a+');
|
|
94
|
+
try {
|
|
95
|
+
await file.write(
|
|
96
|
+
data.subarray(write.dataOffset, write.dataOffset + write.length),
|
|
97
|
+
0,
|
|
98
|
+
write.length,
|
|
99
|
+
write.fileOffset,
|
|
100
|
+
);
|
|
101
|
+
} finally {
|
|
102
|
+
await file.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validate a completed piece and write it only when its SHA-1 hash matches.
|
|
109
|
+
*
|
|
110
|
+
* @param metadata - Parsed torrent metadata.
|
|
111
|
+
* @param piece - Completed piece returned by the planner.
|
|
112
|
+
* @param options - Storage options including output directory.
|
|
113
|
+
* @returns Piece validation result. Invalid pieces are not written to disk.
|
|
114
|
+
* @throws {TorrentStorageError} When storage planning or file writing fails for a valid piece.
|
|
115
|
+
*/
|
|
116
|
+
export const writeValidatedPiece = async (
|
|
117
|
+
metadata: TorrentMetadata,
|
|
118
|
+
piece: PieceCompletion,
|
|
119
|
+
options: WritePieceOptions,
|
|
120
|
+
): Promise<PieceValidationResult> => {
|
|
121
|
+
const validation = validatePiece(piece);
|
|
122
|
+
if (!validation.valid) return validation;
|
|
123
|
+
|
|
124
|
+
await writePiece(metadata, piece.pieceIndex, piece.data, options);
|
|
125
|
+
return validation;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const getStoragePieceLength = (metadata: TorrentMetadata, pieceIndex: number): number => {
|
|
129
|
+
try {
|
|
130
|
+
return getPieceLength(metadata, pieceIndex);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new TorrentStorageError(
|
|
133
|
+
TorrentStorageErrorCode.INVALID_PIECE_INDEX,
|
|
134
|
+
`Unknown piece index: ${pieceIndex}`,
|
|
135
|
+
pieceIndex,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const resolveTorrentFilePath = (outputDirectory: string, parts: string[]): string => {
|
|
141
|
+
if (
|
|
142
|
+
parts.some(
|
|
143
|
+
(part) =>
|
|
144
|
+
part === '' ||
|
|
145
|
+
part === '.' ||
|
|
146
|
+
part === '..' ||
|
|
147
|
+
part.includes('/') ||
|
|
148
|
+
part.includes('\\'),
|
|
149
|
+
)
|
|
150
|
+
) {
|
|
151
|
+
throw new TorrentStorageError(
|
|
152
|
+
TorrentStorageErrorCode.INVALID_FILE_PATH,
|
|
153
|
+
`Invalid torrent file path: ${parts.join('/')}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return join(outputDirectory, ...parts);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export { TorrentStorageError, TorrentStorageErrorCode } from './storage.error';
|
|
161
|
+
export type { FileWrite, WritePieceOptions } from './types';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BunTorrentError } from '@utils/errors';
|
|
2
|
+
|
|
3
|
+
export enum TorrentStorageErrorCode {
|
|
4
|
+
INVALID_FILE_LAYOUT = 'TORRENT_STORAGE_INVALID_FILE_LAYOUT',
|
|
5
|
+
INVALID_PIECE_INDEX = 'TORRENT_STORAGE_INVALID_PIECE_INDEX',
|
|
6
|
+
INVALID_PIECE_LENGTH = 'TORRENT_STORAGE_INVALID_PIECE_LENGTH',
|
|
7
|
+
INVALID_FILE_PATH = 'TORRENT_STORAGE_INVALID_FILE_PATH',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class TorrentStorageError extends BunTorrentError {
|
|
11
|
+
constructor(
|
|
12
|
+
code: TorrentStorageErrorCode,
|
|
13
|
+
message: string,
|
|
14
|
+
public readonly pieceIndex?: number,
|
|
15
|
+
) {
|
|
16
|
+
super(message, code);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type { TorrentMetadata } from '@torrent/types';
|
|
4
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { sha1 } from '@utils/sha1';
|
|
8
|
+
import {
|
|
9
|
+
planPieceWrites,
|
|
10
|
+
TorrentStorageError,
|
|
11
|
+
TorrentStorageErrorCode,
|
|
12
|
+
writePiece,
|
|
13
|
+
writeValidatedPiece,
|
|
14
|
+
} from './index';
|
|
15
|
+
|
|
16
|
+
const makeMetadata = ({
|
|
17
|
+
length,
|
|
18
|
+
pieceLength,
|
|
19
|
+
files,
|
|
20
|
+
}: Pick<TorrentMetadata, 'length' | 'pieceLength' | 'files'>): TorrentMetadata => ({
|
|
21
|
+
announceList: [],
|
|
22
|
+
infoHash: new Uint8Array(20),
|
|
23
|
+
name: 'download',
|
|
24
|
+
pieceLength,
|
|
25
|
+
pieces: Array.from({ length: Math.ceil(length / pieceLength) }, () => new Uint8Array(20)),
|
|
26
|
+
length,
|
|
27
|
+
files,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const readBytes = async (path: string): Promise<number[]> => [...(await Bun.file(path).bytes())];
|
|
31
|
+
|
|
32
|
+
describe('planPieceWrites', () => {
|
|
33
|
+
test('maps a single-file piece to one write', () => {
|
|
34
|
+
const metadata = makeMetadata({
|
|
35
|
+
length: 10,
|
|
36
|
+
pieceLength: 4,
|
|
37
|
+
files: [{ path: ['file.bin'], length: 10, offset: 0 }],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(planPieceWrites(metadata, 1, new Uint8Array([4, 5, 6, 7]))).toEqual([
|
|
41
|
+
{ path: ['file.bin'], fileOffset: 4, dataOffset: 0, length: 4 },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('splits a piece across multiple files', () => {
|
|
46
|
+
const metadata = makeMetadata({
|
|
47
|
+
length: 10,
|
|
48
|
+
pieceLength: 4,
|
|
49
|
+
files: [
|
|
50
|
+
{ path: ['a.bin'], length: 6, offset: 0 },
|
|
51
|
+
{ path: ['nested', 'b.bin'], length: 4, offset: 6 },
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(planPieceWrites(metadata, 1, new Uint8Array([4, 5, 6, 7]))).toEqual([
|
|
56
|
+
{ path: ['a.bin'], fileOffset: 4, dataOffset: 0, length: 2 },
|
|
57
|
+
{ path: ['nested', 'b.bin'], fileOffset: 0, dataOffset: 2, length: 2 },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('maps the final shorter piece', () => {
|
|
62
|
+
const metadata = makeMetadata({
|
|
63
|
+
length: 10,
|
|
64
|
+
pieceLength: 4,
|
|
65
|
+
files: [{ path: ['file.bin'], length: 10, offset: 0 }],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(planPieceWrites(metadata, 2, new Uint8Array([8, 9]))).toEqual([
|
|
69
|
+
{ path: ['file.bin'], fileOffset: 8, dataOffset: 0, length: 2 },
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('splits one piece across three files', () => {
|
|
74
|
+
const metadata = makeMetadata({
|
|
75
|
+
length: 12,
|
|
76
|
+
pieceLength: 6,
|
|
77
|
+
files: [
|
|
78
|
+
{ path: ['a.bin'], length: 3, offset: 0 },
|
|
79
|
+
{ path: ['b.bin'], length: 5, offset: 3 },
|
|
80
|
+
{ path: ['c.bin'], length: 4, offset: 8 },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(planPieceWrites(metadata, 1, new Uint8Array([6, 7, 8, 9, 10, 11]))).toEqual([
|
|
85
|
+
{ path: ['b.bin'], fileOffset: 3, dataOffset: 0, length: 2 },
|
|
86
|
+
{ path: ['c.bin'], fileOffset: 0, dataOffset: 2, length: 4 },
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('rejects invalid piece indexes and lengths', () => {
|
|
91
|
+
const metadata = makeMetadata({
|
|
92
|
+
length: 10,
|
|
93
|
+
pieceLength: 4,
|
|
94
|
+
files: [{ path: ['file.bin'], length: 10, offset: 0 }],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(() => planPieceWrites(metadata, 3, new Uint8Array())).toThrow(TorrentStorageError);
|
|
98
|
+
expect(() => planPieceWrites(metadata, 3, new Uint8Array())).toThrow(
|
|
99
|
+
'Unknown piece index: 3',
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
planPieceWrites(metadata, 3, new Uint8Array());
|
|
104
|
+
throw new Error('Expected planPieceWrites to throw');
|
|
105
|
+
} catch (error) {
|
|
106
|
+
expect((error as TorrentStorageError).code).toBe(
|
|
107
|
+
TorrentStorageErrorCode.INVALID_PIECE_INDEX,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
planPieceWrites(metadata, 2, new Uint8Array([8]));
|
|
113
|
+
throw new Error('Expected planPieceWrites to throw');
|
|
114
|
+
} catch (error) {
|
|
115
|
+
expect((error as TorrentStorageError).code).toBe(
|
|
116
|
+
TorrentStorageErrorCode.INVALID_PIECE_LENGTH,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('rejects file layouts that do not cover the whole piece', () => {
|
|
122
|
+
const metadata = makeMetadata({
|
|
123
|
+
length: 8,
|
|
124
|
+
pieceLength: 4,
|
|
125
|
+
files: [
|
|
126
|
+
{ path: ['a.bin'], length: 2, offset: 0 },
|
|
127
|
+
{ path: ['b.bin'], length: 2, offset: 6 },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(() => planPieceWrites(metadata, 0, new Uint8Array([1, 2, 3, 4]))).toThrow(
|
|
132
|
+
TorrentStorageError,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
planPieceWrites(metadata, 0, new Uint8Array([1, 2, 3, 4]));
|
|
137
|
+
throw new Error('Expected planPieceWrites to throw');
|
|
138
|
+
} catch (error) {
|
|
139
|
+
expect((error as TorrentStorageError).code).toBe(
|
|
140
|
+
TorrentStorageErrorCode.INVALID_FILE_LAYOUT,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('rejects overlapping file layouts for a piece', () => {
|
|
146
|
+
const metadata = makeMetadata({
|
|
147
|
+
length: 6,
|
|
148
|
+
pieceLength: 4,
|
|
149
|
+
files: [
|
|
150
|
+
{ path: ['a.bin'], length: 4, offset: 0 },
|
|
151
|
+
{ path: ['b.bin'], length: 4, offset: 2 },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(() => planPieceWrites(metadata, 0, new Uint8Array([1, 2, 3, 4]))).toThrow(
|
|
156
|
+
TorrentStorageError,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
planPieceWrites(metadata, 0, new Uint8Array([1, 2, 3, 4]));
|
|
161
|
+
throw new Error('Expected planPieceWrites to throw');
|
|
162
|
+
} catch (error) {
|
|
163
|
+
expect((error as TorrentStorageError).code).toBe(
|
|
164
|
+
TorrentStorageErrorCode.INVALID_FILE_LAYOUT,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('writePiece', () => {
|
|
171
|
+
test('writes a piece to a single file at the correct offset', async () => {
|
|
172
|
+
const outputDirectory = await mkdtemp(join(tmpdir(), 'bun-torrent-storage-'));
|
|
173
|
+
const metadata = makeMetadata({
|
|
174
|
+
length: 8,
|
|
175
|
+
pieceLength: 4,
|
|
176
|
+
files: [{ path: ['file.bin'], length: 8, offset: 0 }],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await writePiece(metadata, 0, new Uint8Array([1, 2, 3, 4]), { outputDirectory });
|
|
180
|
+
await writePiece(metadata, 1, new Uint8Array([5, 6, 7, 8]), { outputDirectory });
|
|
181
|
+
|
|
182
|
+
expect(await readBytes(join(outputDirectory, 'file.bin'))).toEqual([
|
|
183
|
+
1, 2, 3, 4, 5, 6, 7, 8,
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('writes a piece across multiple files', async () => {
|
|
188
|
+
const outputDirectory = await mkdtemp(join(tmpdir(), 'bun-torrent-storage-'));
|
|
189
|
+
const metadata = makeMetadata({
|
|
190
|
+
length: 10,
|
|
191
|
+
pieceLength: 4,
|
|
192
|
+
files: [
|
|
193
|
+
{ path: ['a.bin'], length: 6, offset: 0 },
|
|
194
|
+
{ path: ['nested', 'b.bin'], length: 4, offset: 6 },
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await writePiece(metadata, 1, new Uint8Array([4, 5, 6, 7]), { outputDirectory });
|
|
199
|
+
|
|
200
|
+
expect(await readBytes(join(outputDirectory, 'a.bin'))).toEqual([0, 0, 0, 0, 4, 5]);
|
|
201
|
+
expect(await readBytes(join(outputDirectory, 'nested', 'b.bin'))).toEqual([6, 7]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('writes only selected files from a cross-file piece', async () => {
|
|
205
|
+
const outputDirectory = await mkdtemp(join(tmpdir(), 'bun-torrent-storage-'));
|
|
206
|
+
const metadata = makeMetadata({
|
|
207
|
+
length: 10,
|
|
208
|
+
pieceLength: 4,
|
|
209
|
+
files: [
|
|
210
|
+
{ path: ['a.bin'], length: 6, offset: 0 },
|
|
211
|
+
{ path: ['nested', 'b.bin'], length: 4, offset: 6 },
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await writePiece(metadata, 1, new Uint8Array([4, 5, 6, 7]), {
|
|
216
|
+
outputDirectory,
|
|
217
|
+
files: [['nested', 'b.bin']],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(await Bun.file(join(outputDirectory, 'a.bin')).exists()).toBe(false);
|
|
221
|
+
expect(await readBytes(join(outputDirectory, 'nested', 'b.bin'))).toEqual([6, 7]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('creates nested directories before writing', async () => {
|
|
225
|
+
const outputDirectory = await mkdtemp(join(tmpdir(), 'bun-torrent-storage-'));
|
|
226
|
+
const metadata = makeMetadata({
|
|
227
|
+
length: 4,
|
|
228
|
+
pieceLength: 4,
|
|
229
|
+
files: [{ path: ['deep', 'nested', 'file.bin'], length: 4, offset: 0 }],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await writePiece(metadata, 0, new Uint8Array([1, 2, 3, 4]), { outputDirectory });
|
|
233
|
+
|
|
234
|
+
expect(await readBytes(join(outputDirectory, 'deep', 'nested', 'file.bin'))).toEqual([
|
|
235
|
+
1, 2, 3, 4,
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('overwrites the same piece bytes at the same offset', async () => {
|
|
240
|
+
const outputDirectory = await mkdtemp(join(tmpdir(), 'bun-torrent-storage-'));
|
|
241
|
+
const metadata = makeMetadata({
|
|
242
|
+
length: 8,
|
|
243
|
+
pieceLength: 4,
|
|
244
|
+
files: [{ path: ['file.bin'], length: 8, offset: 0 }],
|
|
245
|
+
});
|
|
246
|
+
await writeFile(
|
|
247
|
+
join(outputDirectory, 'file.bin'),
|
|
248
|
+
new Uint8Array([0, 0, 0, 0, 9, 9, 9, 9]),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
await writePiece(metadata, 1, new Uint8Array([5, 6, 7, 8]), { outputDirectory });
|
|
252
|
+
await writePiece(metadata, 1, new Uint8Array([8, 7, 6, 5]), { outputDirectory });
|
|
253
|
+
|
|
254
|
+
expect(await readBytes(join(outputDirectory, 'file.bin'))).toEqual([
|
|
255
|
+
0, 0, 0, 0, 8, 7, 6, 5,
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('rejects unsafe torrent file paths', async () => {
|
|
260
|
+
const outputDirectory = await mkdtemp(join(tmpdir(), 'bun-torrent-storage-'));
|
|
261
|
+
const unsafePaths = [
|
|
262
|
+
['..', 'evil.bin'],
|
|
263
|
+
['.', 'evil.bin'],
|
|
264
|
+
['', 'evil.bin'],
|
|
265
|
+
['nested/evil.bin'],
|
|
266
|
+
['nested\\evil.bin'],
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
for (const path of unsafePaths) {
|
|
270
|
+
const metadata = makeMetadata({
|
|
271
|
+
length: 4,
|
|
272
|
+
pieceLength: 4,
|
|
273
|
+
files: [{ path, length: 4, offset: 0 }],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await expect(
|
|
277
|
+
writePiece(metadata, 0, new Uint8Array([1, 2, 3, 4]), { outputDirectory }),
|
|
278
|
+
).rejects.toMatchObject({
|
|
279
|
+
code: TorrentStorageErrorCode.INVALID_FILE_PATH,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('writeValidatedPiece', () => {
|
|
286
|
+
test('writes a completed piece when its hash matches', async () => {
|
|
287
|
+
const outputDirectory = await mkdtemp(join(tmpdir(), 'bun-torrent-storage-'));
|
|
288
|
+
const data = new Uint8Array([1, 2, 3, 4]);
|
|
289
|
+
const metadata = makeMetadata({
|
|
290
|
+
length: 4,
|
|
291
|
+
pieceLength: 4,
|
|
292
|
+
files: [{ path: ['file.bin'], length: 4, offset: 0 }],
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const result = await writeValidatedPiece(
|
|
296
|
+
metadata,
|
|
297
|
+
{ pieceIndex: 0, data, expectedHash: sha1(data) },
|
|
298
|
+
{ outputDirectory },
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
expect(result.valid).toBe(true);
|
|
302
|
+
expect(await readBytes(join(outputDirectory, 'file.bin'))).toEqual([1, 2, 3, 4]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('does not write a completed piece when its hash does not match', async () => {
|
|
306
|
+
const outputDirectory = await mkdtemp(join(tmpdir(), 'bun-torrent-storage-'));
|
|
307
|
+
const metadata = makeMetadata({
|
|
308
|
+
length: 4,
|
|
309
|
+
pieceLength: 4,
|
|
310
|
+
files: [{ path: ['file.bin'], length: 4, offset: 0 }],
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const result = await writeValidatedPiece(
|
|
314
|
+
metadata,
|
|
315
|
+
{
|
|
316
|
+
pieceIndex: 0,
|
|
317
|
+
data: new Uint8Array([1, 2, 3, 4]),
|
|
318
|
+
expectedHash: sha1(new Uint8Array([4, 3, 2, 1])),
|
|
319
|
+
},
|
|
320
|
+
{ outputDirectory },
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
expect(result.valid).toBe(false);
|
|
324
|
+
expect(await Bun.file(join(outputDirectory, 'file.bin')).exists()).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TorrentFileSelection } from '../file-selection';
|
|
2
|
+
|
|
3
|
+
export type FileWrite = {
|
|
4
|
+
path: string[];
|
|
5
|
+
fileOffset: number;
|
|
6
|
+
dataOffset: number;
|
|
7
|
+
length: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type WritePieceOptions = {
|
|
11
|
+
files?: TorrentFileSelection;
|
|
12
|
+
outputDirectory: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type TorrentFile = {
|
|
2
|
+
path: string[];
|
|
3
|
+
length: number;
|
|
4
|
+
offset: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type TorrentMetadata = {
|
|
8
|
+
announce?: string;
|
|
9
|
+
announceList: string[][];
|
|
10
|
+
infoHash: Uint8Array;
|
|
11
|
+
name: string;
|
|
12
|
+
pieceLength: number;
|
|
13
|
+
pieces: Uint8Array[];
|
|
14
|
+
length: number;
|
|
15
|
+
files: TorrentFile[];
|
|
16
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { encodeBencode, toBValue } from '@torrent/bencode';
|
|
4
|
+
import { parseHttpAnnounceResponse } from './http';
|
|
5
|
+
import { TrackerError, TrackerErrorCode } from './tracker.error';
|
|
6
|
+
|
|
7
|
+
describe('parseHttpAnnounceResponse', () => {
|
|
8
|
+
test('parses compact peers from a tracker response', () => {
|
|
9
|
+
const peers = parseHttpAnnounceResponse(
|
|
10
|
+
encodeBencode(
|
|
11
|
+
toBValue({
|
|
12
|
+
interval: 1_800,
|
|
13
|
+
peers: new Uint8Array([127, 0, 0, 1, 0x1a, 0xe1, 10, 0, 0, 2, 0x13, 0x88]),
|
|
14
|
+
}),
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(peers).toEqual([
|
|
19
|
+
{ ip: '127.0.0.1', port: 6881 },
|
|
20
|
+
{ ip: '10.0.0.2', port: 5000 },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('rejects tracker failure responses', () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
parseHttpAnnounceResponse(
|
|
27
|
+
encodeBencode(toBValue({ 'failure reason': 'torrent not registered' })),
|
|
28
|
+
),
|
|
29
|
+
).toThrow(TrackerError);
|
|
30
|
+
expect(() =>
|
|
31
|
+
parseHttpAnnounceResponse(
|
|
32
|
+
encodeBencode(toBValue({ 'failure reason': 'torrent not registered' })),
|
|
33
|
+
),
|
|
34
|
+
).toThrow('torrent not registered');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('rejects malformed compact peer lists', () => {
|
|
38
|
+
expect(() =>
|
|
39
|
+
parseHttpAnnounceResponse(
|
|
40
|
+
encodeBencode(toBValue({ peers: new Uint8Array([127, 0, 0, 1, 0x1a]) })),
|
|
41
|
+
),
|
|
42
|
+
).toThrow(TrackerError);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
parseHttpAnnounceResponse(
|
|
46
|
+
encodeBencode(toBValue({ peers: new Uint8Array([127, 0, 0, 1, 0x1a]) })),
|
|
47
|
+
);
|
|
48
|
+
throw new Error('Expected parseHttpAnnounceResponse to throw');
|
|
49
|
+
} catch (error) {
|
|
50
|
+
expect((error as TrackerError).code).toBe(TrackerErrorCode.HTTP_RESPONSE_INVALID);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('rejects empty peer lists as no peers', () => {
|
|
55
|
+
expect(() =>
|
|
56
|
+
parseHttpAnnounceResponse(encodeBencode(toBValue({ peers: new Uint8Array() }))),
|
|
57
|
+
).toThrow(TrackerError);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
parseHttpAnnounceResponse(encodeBencode(toBValue({ peers: new Uint8Array() })));
|
|
61
|
+
throw new Error('Expected parseHttpAnnounceResponse to throw');
|
|
62
|
+
} catch (error) {
|
|
63
|
+
expect((error as TrackerError).code).toBe(TrackerErrorCode.NO_PEERS);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|