clawmatrix 0.2.9 → 0.3.1

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.
@@ -0,0 +1,721 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile, stat, mkdir, lstat, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { debug } from "./debug.ts";
5
+ import type { PeerManager } from "./peer-manager.ts";
6
+ import type { ClawMatrixConfig } from "./config.ts";
7
+ import type {
8
+ FileTransferInit,
9
+ FileTransferAck,
10
+ FileTransferChunk,
11
+ FileTransferChunkAck,
12
+ FileTransferComplete,
13
+ } from "./types.ts";
14
+
15
+ interface FileTransferConfig {
16
+ enabled: boolean;
17
+ chunkSize: number;
18
+ maxFileSize: number;
19
+ timeout: number;
20
+ allowedPaths: string[];
21
+ }
22
+
23
+ interface PendingTransfer {
24
+ sessionId: string;
25
+ direction: "push" | "pull";
26
+ filePath: string;
27
+ targetPath: string;
28
+ remoteNode: string;
29
+ resolve: (result: TransferResult) => void;
30
+ reject: (err: Error) => void;
31
+ timer: ReturnType<typeof setTimeout>;
32
+ // Push state
33
+ fileData?: Buffer;
34
+ chunkSize?: number;
35
+ totalChunks?: number;
36
+ sentChunks?: number;
37
+ // Pull state
38
+ chunks?: Map<number, Buffer>;
39
+ expectedSize?: number;
40
+ expectedChunks?: number;
41
+ expectedChecksum?: string;
42
+ receivedChunks?: number;
43
+ onProgress?: (progress: TransferProgress) => void;
44
+ }
45
+
46
+ interface ReceivingTransfer {
47
+ sessionId: string;
48
+ direction: "push" | "pull";
49
+ filePath: string;
50
+ targetPath: string;
51
+ fromNode: string;
52
+ fileSize: number;
53
+ totalChunks: number;
54
+ chunkSize: number;
55
+ checksum: string;
56
+ chunks: Map<number, Buffer>;
57
+ receivedChunks: number;
58
+ timer: ReturnType<typeof setTimeout>;
59
+ /** Cached file data for pull-mode serving to avoid re-reading per chunk */
60
+ cachedData?: Buffer;
61
+ }
62
+
63
+ export interface TransferProgress {
64
+ sessionId: string;
65
+ direction: "push" | "pull";
66
+ sentChunks: number;
67
+ totalChunks: number;
68
+ bytesTransferred: number;
69
+ totalBytes: number;
70
+ }
71
+
72
+ export interface TransferOptions {
73
+ /** Called after each chunk is acknowledged. */
74
+ onProgress?: (progress: TransferProgress) => void;
75
+ }
76
+
77
+ export interface TransferResult {
78
+ success: boolean;
79
+ bytesTransferred: number;
80
+ error?: string;
81
+ }
82
+
83
+ export class FileTransferManager {
84
+ private config: FileTransferConfig;
85
+ private nodeId: string;
86
+ private peerManager: PeerManager;
87
+ private pending = new Map<string, PendingTransfer>();
88
+ private receiving = new Map<string, ReceivingTransfer>();
89
+
90
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
91
+ this.nodeId = config.nodeId;
92
+ this.peerManager = peerManager;
93
+ const ft = config.fileTransfer;
94
+ this.config = {
95
+ enabled: ft?.enabled ?? false,
96
+ chunkSize: ft?.chunkSize ?? 262_144,
97
+ maxFileSize: ft?.maxFileSize ?? 104_857_600,
98
+ timeout: ft?.timeout ?? 300_000,
99
+ allowedPaths: ft?.allowedPaths ?? [],
100
+ };
101
+ }
102
+
103
+ // ── Public API ──────────────────────────────────────────────────
104
+
105
+ async pushFile(remoteNode: string, localPath: string, remotePath: string, opts?: TransferOptions): Promise<TransferResult> {
106
+ this.ensureEnabled();
107
+ const resolvedPath = path.resolve(localPath);
108
+ await this.validatePath(resolvedPath);
109
+
110
+ const fileData = await readFile(resolvedPath);
111
+ if (fileData.length > this.config.maxFileSize) {
112
+ throw new Error(`File too large: ${fileData.length} bytes (max ${this.config.maxFileSize})`);
113
+ }
114
+
115
+ const checksum = createHash("sha256").update(fileData).digest("hex");
116
+ const totalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
117
+ const sessionId = crypto.randomUUID();
118
+
119
+ return new Promise<TransferResult>((resolve, reject) => {
120
+ const timer = this.createTimer(sessionId, "pending");
121
+
122
+ this.pending.set(sessionId, {
123
+ sessionId,
124
+ direction: "push",
125
+ filePath: resolvedPath,
126
+ targetPath: remotePath,
127
+ remoteNode,
128
+ resolve,
129
+ reject,
130
+ timer,
131
+ fileData,
132
+ chunkSize: this.config.chunkSize,
133
+ totalChunks,
134
+ sentChunks: 0,
135
+ onProgress: opts?.onProgress,
136
+ });
137
+
138
+ this.peerManager.sendTo(remoteNode, {
139
+ type: "file_transfer_init",
140
+ id: sessionId,
141
+ from: this.nodeId,
142
+ to: remoteNode,
143
+ timestamp: Date.now(),
144
+ payload: {
145
+ sessionId,
146
+ direction: "push",
147
+ filePath: resolvedPath,
148
+ targetPath: remotePath,
149
+ fileSize: fileData.length,
150
+ totalChunks,
151
+ chunkSize: this.config.chunkSize,
152
+ checksum,
153
+ },
154
+ } as FileTransferInit);
155
+ });
156
+ }
157
+
158
+ async pullFile(remoteNode: string, remotePath: string, localPath: string, opts?: TransferOptions): Promise<TransferResult> {
159
+ this.ensureEnabled();
160
+ const resolvedPath = path.resolve(localPath);
161
+ await this.validatePath(resolvedPath);
162
+
163
+ const sessionId = crypto.randomUUID();
164
+
165
+ return new Promise<TransferResult>((resolve, reject) => {
166
+ const timer = this.createTimer(sessionId, "pending");
167
+
168
+ this.pending.set(sessionId, {
169
+ sessionId,
170
+ direction: "pull",
171
+ filePath: remotePath,
172
+ targetPath: resolvedPath,
173
+ remoteNode,
174
+ resolve,
175
+ reject,
176
+ timer,
177
+ chunks: new Map(),
178
+ receivedChunks: 0,
179
+ onProgress: opts?.onProgress,
180
+ });
181
+
182
+ this.peerManager.sendTo(remoteNode, {
183
+ type: "file_transfer_init",
184
+ id: sessionId,
185
+ from: this.nodeId,
186
+ to: remoteNode,
187
+ timestamp: Date.now(),
188
+ payload: {
189
+ sessionId,
190
+ direction: "pull",
191
+ filePath: remotePath,
192
+ targetPath: resolvedPath,
193
+ fileSize: 0,
194
+ totalChunks: 0,
195
+ chunkSize: this.config.chunkSize,
196
+ checksum: "",
197
+ },
198
+ } as FileTransferInit);
199
+ });
200
+ }
201
+
202
+ // ── Frame handlers (called by cluster-service dispatch) ─────────
203
+
204
+ async handleInit(frame: FileTransferInit): Promise<void> {
205
+ const { sessionId, direction, filePath, targetPath, fileSize, totalChunks, chunkSize, checksum } = frame.payload;
206
+
207
+ // Validate enabled
208
+ if (!this.config.enabled) {
209
+ this.sendAck(frame.from, sessionId, frame.id, false, "File transfer not enabled on this node");
210
+ return;
211
+ }
212
+
213
+ if (direction === "push") {
214
+ // Remote wants to push a file to us
215
+ const resolvedTarget = path.resolve(targetPath);
216
+ if (!(await this.isPathAllowed(resolvedTarget))) {
217
+ this.sendAck(frame.from, sessionId, frame.id, false, "Target path not allowed");
218
+ return;
219
+ }
220
+ if (fileSize > this.config.maxFileSize) {
221
+ this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileSize} bytes (max ${this.config.maxFileSize})`);
222
+ return;
223
+ }
224
+
225
+ // Accept and prepare to receive chunks
226
+ const timer = this.createTimer(sessionId, "receiving");
227
+ this.receiving.set(sessionId, {
228
+ sessionId,
229
+ direction: "push",
230
+ filePath,
231
+ targetPath: resolvedTarget,
232
+ fromNode: frame.from,
233
+ fileSize,
234
+ totalChunks,
235
+ chunkSize,
236
+ checksum,
237
+ chunks: new Map(),
238
+ receivedChunks: 0,
239
+ timer,
240
+ });
241
+
242
+ this.sendAck(frame.from, sessionId, frame.id, true);
243
+ } else {
244
+ // Remote wants to pull a file from us
245
+ const resolvedSource = path.resolve(filePath);
246
+ if (!(await this.isPathAllowed(resolvedSource))) {
247
+ this.sendAck(frame.from, sessionId, frame.id, false, "Source path not allowed");
248
+ return;
249
+ }
250
+
251
+ let fileData: Buffer;
252
+ try {
253
+ fileData = await readFile(resolvedSource);
254
+ } catch (err) {
255
+ this.sendAck(frame.from, sessionId, frame.id, false, `Cannot read file: ${err instanceof Error ? err.message : String(err)}`);
256
+ return;
257
+ }
258
+
259
+ if (fileData.length > this.config.maxFileSize) {
260
+ this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileData.length} bytes`);
261
+ return;
262
+ }
263
+
264
+ const fileChecksum = createHash("sha256").update(fileData).digest("hex");
265
+ const fileTotalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
266
+
267
+ // Send ack with file metadata
268
+ this.peerManager.sendTo(frame.from, {
269
+ type: "file_transfer_ack",
270
+ id: frame.id,
271
+ from: this.nodeId,
272
+ to: frame.from,
273
+ timestamp: Date.now(),
274
+ payload: {
275
+ sessionId,
276
+ accepted: true,
277
+ fileSize: fileData.length,
278
+ totalChunks: fileTotalChunks,
279
+ checksum: fileChecksum,
280
+ },
281
+ } as FileTransferAck);
282
+
283
+ // Store as a "receiving" entry for tracking (we are the sender in pull mode)
284
+ const timer = this.createTimer(sessionId, "receiving");
285
+ this.receiving.set(sessionId, {
286
+ sessionId,
287
+ direction: "pull",
288
+ filePath: resolvedSource,
289
+ targetPath,
290
+ fromNode: frame.from,
291
+ fileSize: fileData.length,
292
+ totalChunks: fileTotalChunks,
293
+ chunkSize: this.config.chunkSize,
294
+ checksum: fileChecksum,
295
+ chunks: new Map(),
296
+ receivedChunks: 0,
297
+ timer,
298
+ cachedData: fileData,
299
+ });
300
+
301
+ // Start sending chunks (stop-and-wait: send first, wait for ack)
302
+ this.sendChunkFromReceiving(sessionId, 0, fileData);
303
+ }
304
+ }
305
+
306
+ handleAck(frame: FileTransferAck): void {
307
+ const { sessionId, accepted, error, fileSize, totalChunks, checksum } = frame.payload;
308
+ const transfer = this.pending.get(sessionId);
309
+ if (!transfer) return;
310
+
311
+ this.resetTimer(sessionId, "pending");
312
+
313
+ if (!accepted) {
314
+ this.completePending(sessionId, { success: false, bytesTransferred: 0, error: error || "Transfer rejected" });
315
+ return;
316
+ }
317
+
318
+ if (transfer.direction === "push") {
319
+ // Start sending chunks
320
+ this.sendNextChunk(sessionId);
321
+ } else {
322
+ // Pull mode: store expected metadata
323
+ transfer.expectedSize = fileSize;
324
+ transfer.expectedChunks = totalChunks;
325
+ transfer.expectedChecksum = checksum;
326
+ // Wait for chunks from remote
327
+ }
328
+ }
329
+
330
+ handleChunk(frame: FileTransferChunk): void {
331
+ const { sessionId, chunkIndex, data } = frame.payload;
332
+
333
+ // Check if this is a pull we initiated (we're receiving)
334
+ const pending = this.pending.get(sessionId);
335
+ if (pending && pending.direction === "pull") {
336
+ this.resetTimer(sessionId, "pending");
337
+ const buf = Buffer.from(data, "base64");
338
+ pending.chunks!.set(chunkIndex, buf);
339
+ pending.receivedChunks = (pending.receivedChunks ?? 0) + 1;
340
+
341
+ // Send chunk ack
342
+ this.peerManager.sendTo(pending.remoteNode, {
343
+ type: "file_transfer_chunk_ack",
344
+ id: frame.id,
345
+ from: this.nodeId,
346
+ to: pending.remoteNode,
347
+ timestamp: Date.now(),
348
+ payload: { sessionId, chunkIndex, success: true },
349
+ } as FileTransferChunkAck);
350
+
351
+ pending.onProgress?.({
352
+ sessionId,
353
+ direction: "pull",
354
+ sentChunks: pending.receivedChunks!,
355
+ totalChunks: pending.expectedChunks ?? 0,
356
+ // Use current chunk size as proxy — Math.min clamps the last (smaller) chunk correctly
357
+ bytesTransferred: Math.min(pending.receivedChunks! * buf.length, pending.expectedSize ?? 0),
358
+ totalBytes: pending.expectedSize ?? 0,
359
+ });
360
+
361
+ // Check if all chunks received
362
+ if (pending.receivedChunks === pending.expectedChunks) {
363
+ this.finalizePull(sessionId).catch((err) => {
364
+ this.completePending(sessionId, {
365
+ success: false,
366
+ bytesTransferred: 0,
367
+ error: err instanceof Error ? err.message : String(err),
368
+ });
369
+ });
370
+ }
371
+ return;
372
+ }
373
+
374
+ // Otherwise, this is a push from remote (we're receiving)
375
+ const receiving = this.receiving.get(sessionId);
376
+ if (!receiving || receiving.direction !== "push") return;
377
+
378
+ this.resetTimer(sessionId, "receiving");
379
+ const buf = Buffer.from(data, "base64");
380
+ receiving.chunks.set(chunkIndex, buf);
381
+ receiving.receivedChunks++;
382
+
383
+ // Send chunk ack
384
+ this.peerManager.sendTo(receiving.fromNode, {
385
+ type: "file_transfer_chunk_ack",
386
+ id: frame.id,
387
+ from: this.nodeId,
388
+ to: receiving.fromNode,
389
+ timestamp: Date.now(),
390
+ payload: { sessionId, chunkIndex, success: true },
391
+ } as FileTransferChunkAck);
392
+
393
+ // Check if all chunks received
394
+ if (receiving.receivedChunks === receiving.totalChunks) {
395
+ this.finalizePushReceive(sessionId).catch((err) => {
396
+ debug("file-transfer", `finalizePushReceive error: ${err}`);
397
+ });
398
+ }
399
+ }
400
+
401
+ handleChunkAck(frame: FileTransferChunkAck): void {
402
+ const { sessionId, chunkIndex, success, error } = frame.payload;
403
+
404
+ // Push mode: we sent a chunk, got ack, send next
405
+ const pending = this.pending.get(sessionId);
406
+ if (pending && pending.direction === "push") {
407
+ this.resetTimer(sessionId, "pending");
408
+ if (!success) {
409
+ this.completePending(sessionId, {
410
+ success: false,
411
+ bytesTransferred: chunkIndex * (pending.chunkSize ?? this.config.chunkSize),
412
+ error: error || `Chunk ${chunkIndex} rejected`,
413
+ });
414
+ return;
415
+ }
416
+ pending.sentChunks = chunkIndex + 1;
417
+ pending.onProgress?.({
418
+ sessionId,
419
+ direction: "push",
420
+ sentChunks: pending.sentChunks!,
421
+ totalChunks: pending.totalChunks!,
422
+ bytesTransferred: Math.min(pending.sentChunks! * (pending.chunkSize ?? this.config.chunkSize), pending.fileData?.length ?? 0),
423
+ totalBytes: pending.fileData?.length ?? 0,
424
+ });
425
+ if (pending.sentChunks! < pending.totalChunks!) {
426
+ this.sendNextChunk(sessionId);
427
+ }
428
+ // If all sent, wait for file_transfer_complete from receiver
429
+ return;
430
+ }
431
+
432
+ // Pull mode (we are serving): got chunk ack, send next
433
+ const receiving = this.receiving.get(sessionId);
434
+ if (receiving && receiving.direction === "pull") {
435
+ this.resetTimer(sessionId, "receiving");
436
+ if (!success) {
437
+ this.cleanupReceiving(sessionId);
438
+ return;
439
+ }
440
+ const nextIndex = chunkIndex + 1;
441
+ if (nextIndex < receiving.totalChunks) {
442
+ if (receiving.cachedData) {
443
+ this.sendChunkFromReceiving(sessionId, nextIndex, receiving.cachedData);
444
+ } else {
445
+ // Fallback: read file if cached data is missing
446
+ readFile(receiving.filePath).then((data) => {
447
+ receiving.cachedData = data;
448
+ this.sendChunkFromReceiving(sessionId, nextIndex, data);
449
+ }).catch(() => {
450
+ this.cleanupReceiving(sessionId);
451
+ });
452
+ }
453
+ }
454
+ // If all chunks sent, wait for file_transfer_complete from puller
455
+ }
456
+ }
457
+
458
+ handleComplete(frame: FileTransferComplete): void {
459
+ const { sessionId, success, error, bytesTransferred } = frame.payload;
460
+
461
+ // Pending transfer completed
462
+ const pending = this.pending.get(sessionId);
463
+ if (pending) {
464
+ this.completePending(sessionId, {
465
+ success,
466
+ bytesTransferred: bytesTransferred ?? 0,
467
+ error: error || undefined,
468
+ });
469
+ return;
470
+ }
471
+
472
+ // Receiving transfer completed (pull mode serving — remote confirms)
473
+ const receiving = this.receiving.get(sessionId);
474
+ if (receiving) {
475
+ this.cleanupReceiving(sessionId);
476
+ }
477
+ }
478
+
479
+ destroy(): void {
480
+ for (const [, transfer] of this.pending) {
481
+ clearTimeout(transfer.timer);
482
+ transfer.reject(new Error("FileTransferManager destroyed"));
483
+ }
484
+ this.pending.clear();
485
+
486
+ for (const [, transfer] of this.receiving) {
487
+ clearTimeout(transfer.timer);
488
+ }
489
+ this.receiving.clear();
490
+ }
491
+
492
+ // ── Internal helpers ────────────────────────────────────────────
493
+
494
+ private ensureEnabled(): void {
495
+ if (!this.config.enabled) {
496
+ throw new Error("File transfer is not enabled");
497
+ }
498
+ }
499
+
500
+ private async validatePath(resolvedPath: string): Promise<void> {
501
+ if (!(await this.isPathAllowed(resolvedPath))) {
502
+ throw new Error(`Path not allowed: ${resolvedPath}`);
503
+ }
504
+ }
505
+
506
+ private async isPathAllowed(resolvedPath: string): Promise<boolean> {
507
+ // TODO(security): allowedPaths 为空时默认允许所有路径,当前仅用于受信任网络。
508
+ // 开放到非受信环境前需改为默认拒绝,或要求显式配置 allowedPaths。
509
+ if (this.config.allowedPaths.length === 0) return true;
510
+
511
+ // Resolve symlinks to prevent path traversal via symlink
512
+ let realResolved: string;
513
+ try {
514
+ realResolved = await realpath(resolvedPath);
515
+ } catch {
516
+ // File doesn't exist yet (for write targets) — check parent directory
517
+ const parentDir = path.dirname(resolvedPath);
518
+ try {
519
+ realResolved = path.join(await realpath(parentDir), path.basename(resolvedPath));
520
+ } catch {
521
+ // Parent doesn't exist either — use the resolved path as-is
522
+ realResolved = resolvedPath;
523
+ }
524
+ }
525
+
526
+ return this.config.allowedPaths.some((allowed) => {
527
+ const resolvedAllowed = path.resolve(allowed);
528
+ return realResolved === resolvedAllowed || realResolved.startsWith(resolvedAllowed + path.sep);
529
+ });
530
+ }
531
+
532
+ private sendAck(to: string, sessionId: string, frameId: string, accepted: boolean, error?: string): void {
533
+ this.peerManager.sendTo(to, {
534
+ type: "file_transfer_ack",
535
+ id: frameId,
536
+ from: this.nodeId,
537
+ to,
538
+ timestamp: Date.now(),
539
+ payload: { sessionId, accepted, error },
540
+ } as FileTransferAck);
541
+ }
542
+
543
+ private sendNextChunk(sessionId: string): void {
544
+ const transfer = this.pending.get(sessionId);
545
+ if (!transfer || !transfer.fileData) return;
546
+
547
+ const chunkIndex = transfer.sentChunks!;
548
+ const start = chunkIndex * transfer.chunkSize!;
549
+ const end = Math.min(start + transfer.chunkSize!, transfer.fileData.length);
550
+ const chunk = transfer.fileData.subarray(start, end);
551
+
552
+ this.peerManager.sendTo(transfer.remoteNode, {
553
+ type: "file_transfer_chunk",
554
+ id: transfer.sessionId,
555
+ from: this.nodeId,
556
+ to: transfer.remoteNode,
557
+ timestamp: Date.now(),
558
+ payload: {
559
+ sessionId,
560
+ chunkIndex,
561
+ data: chunk.toString("base64"),
562
+ },
563
+ } as FileTransferChunk);
564
+ }
565
+
566
+ private sendChunkFromReceiving(sessionId: string, chunkIndex: number, fileData: Buffer): void {
567
+ const receiving = this.receiving.get(sessionId);
568
+ if (!receiving) return;
569
+
570
+ const start = chunkIndex * receiving.chunkSize;
571
+ const end = Math.min(start + receiving.chunkSize, fileData.length);
572
+ const chunk = fileData.subarray(start, end);
573
+
574
+ this.peerManager.sendTo(receiving.fromNode, {
575
+ type: "file_transfer_chunk",
576
+ id: sessionId,
577
+ from: this.nodeId,
578
+ to: receiving.fromNode,
579
+ timestamp: Date.now(),
580
+ payload: {
581
+ sessionId,
582
+ chunkIndex,
583
+ data: chunk.toString("base64"),
584
+ },
585
+ } as FileTransferChunk);
586
+ }
587
+
588
+ private async finalizePushReceive(sessionId: string): Promise<void> {
589
+ const receiving = this.receiving.get(sessionId);
590
+ if (!receiving) return;
591
+
592
+ // Reassemble file
593
+ const buffers: Buffer[] = [];
594
+ for (let i = 0; i < receiving.totalChunks; i++) {
595
+ const chunk = receiving.chunks.get(i);
596
+ if (!chunk) {
597
+ this.sendComplete(receiving.fromNode, sessionId, false, "Missing chunk " + i);
598
+ this.cleanupReceiving(sessionId);
599
+ return;
600
+ }
601
+ buffers.push(chunk);
602
+ }
603
+
604
+ const assembled = Buffer.concat(buffers);
605
+
606
+ // Verify checksum
607
+ const actualChecksum = createHash("sha256").update(assembled).digest("hex");
608
+ if (actualChecksum !== receiving.checksum) {
609
+ this.sendComplete(receiving.fromNode, sessionId, false, `Checksum mismatch: expected ${receiving.checksum}, got ${actualChecksum}`);
610
+ this.cleanupReceiving(sessionId);
611
+ return;
612
+ }
613
+
614
+ // Write file
615
+ try {
616
+ await mkdir(path.dirname(receiving.targetPath), { recursive: true });
617
+ await writeFile(receiving.targetPath, assembled);
618
+ } catch (err) {
619
+ this.sendComplete(receiving.fromNode, sessionId, false, `Write error: ${err instanceof Error ? err.message : String(err)}`);
620
+ this.cleanupReceiving(sessionId);
621
+ return;
622
+ }
623
+
624
+ this.sendComplete(receiving.fromNode, sessionId, true, undefined, assembled.length);
625
+ this.cleanupReceiving(sessionId);
626
+ }
627
+
628
+ private async finalizePull(sessionId: string): Promise<void> {
629
+ const transfer = this.pending.get(sessionId);
630
+ if (!transfer) return;
631
+
632
+ // Reassemble file
633
+ const buffers: Buffer[] = [];
634
+ for (let i = 0; i < transfer.expectedChunks!; i++) {
635
+ const chunk = transfer.chunks!.get(i);
636
+ if (!chunk) {
637
+ throw new Error(`Missing chunk ${i}`);
638
+ }
639
+ buffers.push(chunk);
640
+ }
641
+
642
+ const assembled = Buffer.concat(buffers);
643
+
644
+ // Verify checksum
645
+ const actualChecksum = createHash("sha256").update(assembled).digest("hex");
646
+ if (actualChecksum !== transfer.expectedChecksum) {
647
+ // Notify remote of failure
648
+ this.sendComplete(transfer.remoteNode, sessionId, false, `Checksum mismatch: expected ${transfer.expectedChecksum}, got ${actualChecksum}`);
649
+ this.completePending(sessionId, {
650
+ success: false,
651
+ bytesTransferred: 0,
652
+ error: `Checksum mismatch`,
653
+ });
654
+ return;
655
+ }
656
+
657
+ // Write file
658
+ await mkdir(path.dirname(transfer.targetPath), { recursive: true });
659
+ await writeFile(transfer.targetPath, assembled);
660
+
661
+ // Notify remote of success
662
+ this.sendComplete(transfer.remoteNode, sessionId, true, undefined, assembled.length);
663
+ this.completePending(sessionId, { success: true, bytesTransferred: assembled.length });
664
+ }
665
+
666
+ private sendComplete(to: string, sessionId: string, success: boolean, error?: string, bytesTransferred?: number): void {
667
+ this.peerManager.sendTo(to, {
668
+ type: "file_transfer_complete",
669
+ id: sessionId,
670
+ from: this.nodeId,
671
+ to,
672
+ timestamp: Date.now(),
673
+ payload: { sessionId, success, error, bytesTransferred },
674
+ } as FileTransferComplete);
675
+ }
676
+
677
+ private completePending(sessionId: string, result: TransferResult): void {
678
+ const transfer = this.pending.get(sessionId);
679
+ if (!transfer) return;
680
+ clearTimeout(transfer.timer);
681
+ this.pending.delete(sessionId);
682
+ transfer.resolve(result);
683
+ }
684
+
685
+ private cleanupReceiving(sessionId: string): void {
686
+ const receiving = this.receiving.get(sessionId);
687
+ if (!receiving) return;
688
+ clearTimeout(receiving.timer);
689
+ this.receiving.delete(sessionId);
690
+ }
691
+
692
+ private createTimer(sessionId: string, mapType: "pending" | "receiving"): ReturnType<typeof setTimeout> {
693
+ return setTimeout(() => {
694
+ if (mapType === "pending") {
695
+ const transfer = this.pending.get(sessionId);
696
+ if (transfer) {
697
+ this.pending.delete(sessionId);
698
+ transfer.reject(new Error("File transfer timeout"));
699
+ }
700
+ } else {
701
+ this.receiving.delete(sessionId);
702
+ }
703
+ }, this.config.timeout);
704
+ }
705
+
706
+ private resetTimer(sessionId: string, mapType: "pending" | "receiving"): void {
707
+ if (mapType === "pending") {
708
+ const transfer = this.pending.get(sessionId);
709
+ if (transfer) {
710
+ clearTimeout(transfer.timer);
711
+ transfer.timer = this.createTimer(sessionId, "pending");
712
+ }
713
+ } else {
714
+ const receiving = this.receiving.get(sessionId);
715
+ if (receiving) {
716
+ clearTimeout(receiving.timer);
717
+ receiving.timer = this.createTimer(sessionId, "receiving");
718
+ }
719
+ }
720
+ }
721
+ }