@vdoninja/ninja-p2p 0.1.4 → 0.2.0
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/.agents/skills/ninja-p2p/SKILL.md +102 -2
- package/.claude/skills/ninja-p2p/SKILL.md +209 -130
- package/.codex/skills/ninja-p2p/SKILL.md +102 -2
- package/CHANGELOG.md +113 -0
- package/README.md +1077 -775
- package/dashboard.html +370 -50
- package/dist/agent-state.js +9 -0
- package/dist/cli-lib.d.ts +40 -0
- package/dist/cli-lib.js +165 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +501 -10
- package/dist/demo.d.ts +37 -0
- package/dist/demo.js +186 -0
- package/dist/doctor.d.ts +52 -0
- package/dist/doctor.js +219 -0
- package/dist/file-transfer.d.ts +15 -2
- package/dist/file-transfer.js +249 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/message-bus.d.ts +15 -0
- package/dist/message-bus.js +32 -3
- package/dist/peer-registry.js +7 -0
- package/dist/protocol.d.ts +78 -1
- package/dist/protocol.js +42 -6
- package/dist/shared-folders.js +20 -1
- package/dist/social-stream.d.ts +100 -0
- package/dist/social-stream.js +254 -0
- package/dist/swarm-manager.d.ts +164 -0
- package/dist/swarm-manager.js +957 -0
- package/dist/swarm-session.d.ts +197 -0
- package/dist/swarm-session.js +465 -0
- package/dist/swarm-wire.d.ts +48 -0
- package/dist/swarm-wire.js +86 -0
- package/dist/swarm.d.ts +258 -0
- package/dist/swarm.js +694 -0
- package/dist/vdo-bridge.d.ts +62 -1
- package/dist/vdo-bridge.js +273 -23
- package/dist/vdoninja-sdk-types.d.ts +75 -0
- package/dist/vdoninja-sdk-types.js +10 -0
- package/dist/wake.d.ts +83 -0
- package/dist/wake.js +206 -0
- package/docs/protocol-and-reliability.md +231 -38
- package/docs/sdk-wishlist.md +236 -0
- package/docs/security.md +197 -0
- package/docs/social-stream-bridge.md +390 -0
- package/package.json +125 -116
package/dist/swarm.js
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm Transfer
|
|
3
|
+
*
|
|
4
|
+
* The original file transfer was one sender pushing base64 chunks in strict
|
|
5
|
+
* order to one receiver. That caps throughput at a single peer's upload, breaks
|
|
6
|
+
* if a chunk arrives out of order, and cannot resume from anyone else.
|
|
7
|
+
*
|
|
8
|
+
* This is the torrent-shaped replacement. Files are content-addressed by their
|
|
9
|
+
* sha256, so every peer holding the same bytes is interchangeable. Peers publish
|
|
10
|
+
* a bitfield of the chunks they hold, chunks are pulled rather than pushed, and
|
|
11
|
+
* a peer can start serving a chunk the moment it has verified it — while still
|
|
12
|
+
* downloading the rest.
|
|
13
|
+
*
|
|
14
|
+
* The SDK gives one data channel per peer, so parallelism comes from two places:
|
|
15
|
+
* requesting from several peers at once, and keeping several requests in flight
|
|
16
|
+
* on each channel rather than waiting for each to land.
|
|
17
|
+
*
|
|
18
|
+
* This module is deliberately pure and I/O-light so the hard parts — bitfields,
|
|
19
|
+
* piece selection, verification — are testable without a network.
|
|
20
|
+
*/
|
|
21
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, readFileSync, rmSync, statSync, utimesSync, writeSync, } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
24
|
+
export const DEFAULT_SWARM_CHUNK_SIZE = 64_000;
|
|
25
|
+
export const DEFAULT_MAX_IN_FLIGHT_PER_PEER = 4;
|
|
26
|
+
export const DEFAULT_MAX_IN_FLIGHT_TOTAL = 24;
|
|
27
|
+
/** Keep each paged manifest message comfortably below any SCTP limit. */
|
|
28
|
+
export const SWARM_MANIFEST_PAGE_HASHES = 256;
|
|
29
|
+
/** Small files stay one-message simple; large files request hash pages on demand. */
|
|
30
|
+
export const SWARM_INLINE_MANIFEST_HASHES = 256;
|
|
31
|
+
/** Bounds allocations from a peer-controlled manifest while still allowing ~64 GB at 64 KB chunks. */
|
|
32
|
+
export const MAX_SWARM_TOTAL_CHUNKS = 1_000_000;
|
|
33
|
+
/** A chunk must fit in memory and, in practice, one data-channel message. */
|
|
34
|
+
export const MAX_SWARM_CHUNK_SIZE = 16 * 1024 * 1024;
|
|
35
|
+
export const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
36
|
+
/** Room left for the JSON envelope wrapped around a base64 chunk. */
|
|
37
|
+
export const SWARM_ENVELOPE_HEADROOM = 4_096;
|
|
38
|
+
/** After this long with no progress, a lock is treated as abandoned. */
|
|
39
|
+
export const SWARM_LOCK_STALE_MS = 5 * 60_000;
|
|
40
|
+
/**
|
|
41
|
+
* Where an in-progress download is written.
|
|
42
|
+
*
|
|
43
|
+
* Keyed by content **and destination**, not content alone. Two `fetch` runs of
|
|
44
|
+
* the same file into different folders are a perfectly ordinary thing to do,
|
|
45
|
+
* and sharing one `.part` between them meant they wrote over each other and the
|
|
46
|
+
* first to finish renamed the file out from under the rest — which surfaced as
|
|
47
|
+
* an ENOENT crash, having already corrupted whatever the others had written.
|
|
48
|
+
*
|
|
49
|
+
* Keeping the destination in the key also preserves resume: the same fetch, run
|
|
50
|
+
* again, lands on the same part file and picks up where it left off.
|
|
51
|
+
*/
|
|
52
|
+
export function partPathFor(workDir, fileId, savedPath) {
|
|
53
|
+
if (!isSha256Hex(fileId)) {
|
|
54
|
+
throw new Error("swarm file id must be a lowercase sha256 hex digest");
|
|
55
|
+
}
|
|
56
|
+
const destination = sha256Hex(new TextEncoder().encode(path.resolve(savedPath))).slice(0, 12);
|
|
57
|
+
return path.join(workDir, `${fileId}-${destination}.part`);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Largest chunk that still fits one SCTP message once base64-encoded.
|
|
61
|
+
*
|
|
62
|
+
* Only the fallback path needs this. Binary chunks add a 40-byte header, so a
|
|
63
|
+
* 64 KB chunk fits inside even the 65536 every WebRTC implementation must
|
|
64
|
+
* support. Base64 inflates by 4/3, so the same chunk becomes 85 KB and would be
|
|
65
|
+
* refused by a peer that negotiated only that minimum — a failure that would
|
|
66
|
+
* only ever show up against an implementation we had not tested with.
|
|
67
|
+
*
|
|
68
|
+
* Every implementation measured here negotiates 262144, which is why this has
|
|
69
|
+
* never bitten. It is a guard, not a tuning knob: chunk size was measured
|
|
70
|
+
* against throughput at 64 KB, 128 KB and 192 KB and moved it by under 2%.
|
|
71
|
+
*/
|
|
72
|
+
export function maxSafeChunkSize(messageLimit) {
|
|
73
|
+
const budget = messageLimit - SWARM_ENVELOPE_HEADROOM;
|
|
74
|
+
if (budget <= 0)
|
|
75
|
+
return 1_024;
|
|
76
|
+
return Math.max(1_024, Math.floor((budget * 3) / 4));
|
|
77
|
+
}
|
|
78
|
+
// ── Chunk bitfield ───────────────────────────────────────────────────────────
|
|
79
|
+
/**
|
|
80
|
+
* Which chunks a peer holds.
|
|
81
|
+
*
|
|
82
|
+
* A bitfield rather than a list: a 4 GB file at 64 KB chunks is ~65k chunks,
|
|
83
|
+
* which is 8 KB as bits and would be megabytes as JSON indices. Peers exchange
|
|
84
|
+
* these constantly, so the compact form matters.
|
|
85
|
+
*/
|
|
86
|
+
export class ChunkMap {
|
|
87
|
+
totalChunks;
|
|
88
|
+
bits;
|
|
89
|
+
present = 0;
|
|
90
|
+
constructor(totalChunks, bits) {
|
|
91
|
+
if (!Number.isInteger(totalChunks) || totalChunks < 0) {
|
|
92
|
+
throw new Error(`invalid totalChunks: ${totalChunks}`);
|
|
93
|
+
}
|
|
94
|
+
this.totalChunks = totalChunks;
|
|
95
|
+
this.bits = bits ?? new Uint8Array(byteLengthFor(totalChunks));
|
|
96
|
+
if (this.bits.length < byteLengthFor(totalChunks)) {
|
|
97
|
+
throw new Error("bitfield too short for totalChunks");
|
|
98
|
+
}
|
|
99
|
+
this.present = this.recount();
|
|
100
|
+
}
|
|
101
|
+
static full(totalChunks) {
|
|
102
|
+
const map = new ChunkMap(totalChunks);
|
|
103
|
+
for (let i = 0; i < totalChunks; i += 1)
|
|
104
|
+
map.set(i);
|
|
105
|
+
return map;
|
|
106
|
+
}
|
|
107
|
+
static fromBase64(value, totalChunks) {
|
|
108
|
+
const bits = new Uint8Array(Buffer.from(value, "base64"));
|
|
109
|
+
const needed = byteLengthFor(totalChunks);
|
|
110
|
+
if (bits.length < needed) {
|
|
111
|
+
const padded = new Uint8Array(needed);
|
|
112
|
+
padded.set(bits);
|
|
113
|
+
return new ChunkMap(totalChunks, padded);
|
|
114
|
+
}
|
|
115
|
+
return new ChunkMap(totalChunks, bits.subarray(0, needed));
|
|
116
|
+
}
|
|
117
|
+
toBase64() {
|
|
118
|
+
return Buffer.from(this.bits).toString("base64");
|
|
119
|
+
}
|
|
120
|
+
has(index) {
|
|
121
|
+
if (index < 0 || index >= this.totalChunks)
|
|
122
|
+
return false;
|
|
123
|
+
return (this.bits[index >> 3] & (1 << (index & 7))) !== 0;
|
|
124
|
+
}
|
|
125
|
+
set(index) {
|
|
126
|
+
if (index < 0 || index >= this.totalChunks)
|
|
127
|
+
return false;
|
|
128
|
+
if (this.has(index))
|
|
129
|
+
return false;
|
|
130
|
+
this.bits[index >> 3] |= 1 << (index & 7);
|
|
131
|
+
this.present += 1;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
count() {
|
|
135
|
+
return this.present;
|
|
136
|
+
}
|
|
137
|
+
isComplete() {
|
|
138
|
+
return this.present === this.totalChunks;
|
|
139
|
+
}
|
|
140
|
+
missing() {
|
|
141
|
+
const out = [];
|
|
142
|
+
for (let i = 0; i < this.totalChunks; i += 1) {
|
|
143
|
+
if (!this.has(i))
|
|
144
|
+
out.push(i);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
clone() {
|
|
149
|
+
return new ChunkMap(this.totalChunks, Uint8Array.from(this.bits));
|
|
150
|
+
}
|
|
151
|
+
recount() {
|
|
152
|
+
let total = 0;
|
|
153
|
+
for (let i = 0; i < this.totalChunks; i += 1) {
|
|
154
|
+
if ((this.bits[i >> 3] & (1 << (i & 7))) !== 0)
|
|
155
|
+
total += 1;
|
|
156
|
+
}
|
|
157
|
+
return total;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function byteLengthFor(totalChunks) {
|
|
161
|
+
return Math.ceil(totalChunks / 8);
|
|
162
|
+
}
|
|
163
|
+
/** RTT assumed for a peer we have not measured yet, so new peers still get tried. */
|
|
164
|
+
export const UNKNOWN_PEER_RTT_MS = 250;
|
|
165
|
+
const FAILURE_PENALTY_MS = 2_000;
|
|
166
|
+
const QUEUE_PENALTY_MS = 250;
|
|
167
|
+
const PROVEN_PEER_BONUS_MS = 10;
|
|
168
|
+
const PROVEN_PEER_BONUS_CAP = 20;
|
|
169
|
+
export function createPeerStats() {
|
|
170
|
+
return { rttMs: null, failures: 0, delivered: 0 };
|
|
171
|
+
}
|
|
172
|
+
/** Fold a new RTT sample in. Weighted toward history so one spike cannot swing it. */
|
|
173
|
+
export function recordPeerRtt(stats, sampleMs) {
|
|
174
|
+
if (!Number.isFinite(sampleMs) || sampleMs < 0)
|
|
175
|
+
return stats;
|
|
176
|
+
stats.rttMs = stats.rttMs === null ? sampleMs : stats.rttMs * 0.7 + sampleMs * 0.3;
|
|
177
|
+
return stats;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Rank a peer for serving the next chunk. Lower is better, and the units are
|
|
181
|
+
* milliseconds throughout so the number stays explainable: it is roughly
|
|
182
|
+
* "expected delay if we ask this peer".
|
|
183
|
+
*/
|
|
184
|
+
export function scorePeer(peer) {
|
|
185
|
+
const stats = peer.stats;
|
|
186
|
+
let score = stats?.rttMs ?? UNKNOWN_PEER_RTT_MS;
|
|
187
|
+
score += (stats?.failures ?? 0) * FAILURE_PENALTY_MS;
|
|
188
|
+
score += peer.inFlight * QUEUE_PENALTY_MS;
|
|
189
|
+
score -= Math.min(stats?.delivered ?? 0, PROVEN_PEER_BONUS_CAP) * PROVEN_PEER_BONUS_MS;
|
|
190
|
+
return score;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Decide what to ask for next.
|
|
194
|
+
*
|
|
195
|
+
* Rarest-first: chunks held by the fewest peers are fetched first, so a swarm
|
|
196
|
+
* does not lose the only copy of a chunk when the peer holding it leaves. Among
|
|
197
|
+
* peers that can serve a chunk, the best-scoring one wins — measured latency,
|
|
198
|
+
* observed failures, and current queue depth — which spreads load and routes
|
|
199
|
+
* around bad peers without any central coordination.
|
|
200
|
+
*
|
|
201
|
+
* Ties are broken randomly, and that is load-bearing rather than a detail. Ties
|
|
202
|
+
* are the normal case at the start of a download, when every chunk has exactly
|
|
203
|
+
* the same rarity. Breaking them by index meant every downloader asked for the
|
|
204
|
+
* same chunks in the same order, so downloaders never held anything the others
|
|
205
|
+
* lacked and could never serve each other — three leechers against one seeder
|
|
206
|
+
* measured at exactly one third the speed of one, with no peer-to-peer traffic
|
|
207
|
+
* at all. Random selection makes them diverge immediately.
|
|
208
|
+
*/
|
|
209
|
+
export function planChunkRequests(have, peers, inFlightIndices, options = {}) {
|
|
210
|
+
const maxPerPeer = options.maxInFlightPerPeer ?? DEFAULT_MAX_IN_FLIGHT_PER_PEER;
|
|
211
|
+
const maxTotal = options.maxInFlightTotal ?? DEFAULT_MAX_IN_FLIGHT_TOTAL;
|
|
212
|
+
const budget = new Map();
|
|
213
|
+
let totalInFlight = 0;
|
|
214
|
+
for (const peer of peers) {
|
|
215
|
+
budget.set(peer.peerId, Math.max(0, maxPerPeer - peer.inFlight));
|
|
216
|
+
totalInFlight += peer.inFlight;
|
|
217
|
+
}
|
|
218
|
+
let remainingTotal = Math.max(0, maxTotal - totalInFlight);
|
|
219
|
+
if (remainingTotal === 0)
|
|
220
|
+
return [];
|
|
221
|
+
// Rarity across the peers we can actually reach right now.
|
|
222
|
+
const random = options.random ?? Math.random;
|
|
223
|
+
const candidates = [];
|
|
224
|
+
for (let index = 0; index < have.totalChunks; index += 1) {
|
|
225
|
+
if (have.has(index) || inFlightIndices.has(index))
|
|
226
|
+
continue;
|
|
227
|
+
let rarity = 0;
|
|
228
|
+
for (const peer of peers) {
|
|
229
|
+
if (peer.chunks.has(index))
|
|
230
|
+
rarity += 1;
|
|
231
|
+
}
|
|
232
|
+
if (rarity > 0)
|
|
233
|
+
candidates.push({ index, rarity, tiebreak: random() });
|
|
234
|
+
}
|
|
235
|
+
// Rarest first, then random among equals — see the note above.
|
|
236
|
+
candidates.sort((a, b) => a.rarity - b.rarity || a.tiebreak - b.tiebreak);
|
|
237
|
+
const plans = [];
|
|
238
|
+
const assigned = new Map();
|
|
239
|
+
for (const candidate of candidates) {
|
|
240
|
+
if (remainingTotal === 0)
|
|
241
|
+
break;
|
|
242
|
+
let chosen = null;
|
|
243
|
+
let chosenScore = Number.POSITIVE_INFINITY;
|
|
244
|
+
for (const peer of peers) {
|
|
245
|
+
if (!peer.chunks.has(candidate.index))
|
|
246
|
+
continue;
|
|
247
|
+
if ((budget.get(peer.peerId) ?? 0) <= 0)
|
|
248
|
+
continue;
|
|
249
|
+
// Count work already assigned in this same pass, so one fast peer does
|
|
250
|
+
// not get handed the entire batch.
|
|
251
|
+
const score = scorePeer({
|
|
252
|
+
...peer,
|
|
253
|
+
inFlight: peer.inFlight + (assigned.get(peer.peerId) ?? 0),
|
|
254
|
+
});
|
|
255
|
+
if (score < chosenScore) {
|
|
256
|
+
chosen = peer.peerId;
|
|
257
|
+
chosenScore = score;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!chosen)
|
|
261
|
+
continue;
|
|
262
|
+
plans.push({ peerId: chosen, index: candidate.index });
|
|
263
|
+
budget.set(chosen, (budget.get(chosen) ?? 0) - 1);
|
|
264
|
+
assigned.set(chosen, (assigned.get(chosen) ?? 0) + 1);
|
|
265
|
+
remainingTotal -= 1;
|
|
266
|
+
}
|
|
267
|
+
return plans;
|
|
268
|
+
}
|
|
269
|
+
// ── Chunk storage ────────────────────────────────────────────────────────────
|
|
270
|
+
export function chunkOffset(index, chunkSize) {
|
|
271
|
+
return index * chunkSize;
|
|
272
|
+
}
|
|
273
|
+
export function chunkLength(index, manifest) {
|
|
274
|
+
const start = index * manifest.chunkSize;
|
|
275
|
+
return Math.max(0, Math.min(manifest.chunkSize, manifest.size - start));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* A partially downloaded file backed by a sparse `.part` on disk.
|
|
279
|
+
*
|
|
280
|
+
* Chunks are written at their byte offset rather than appended, which is what
|
|
281
|
+
* makes out-of-order and multi-source delivery possible at all.
|
|
282
|
+
*/
|
|
283
|
+
export class ChunkFile {
|
|
284
|
+
filePath;
|
|
285
|
+
manifest;
|
|
286
|
+
fd = null;
|
|
287
|
+
lockPath = null;
|
|
288
|
+
lockToken = null;
|
|
289
|
+
/**
|
|
290
|
+
* @param exclusive Refuse to open if another live process holds this file.
|
|
291
|
+
* Only a download needs it — a seed session opens the user's own file
|
|
292
|
+
* read-mostly and several of those coexisting is fine.
|
|
293
|
+
*/
|
|
294
|
+
constructor(filePath, manifest, exclusive = false) {
|
|
295
|
+
this.filePath = filePath;
|
|
296
|
+
this.manifest = manifest;
|
|
297
|
+
if (exclusive)
|
|
298
|
+
this.lockPath = `${filePath}.lock`;
|
|
299
|
+
}
|
|
300
|
+
open() {
|
|
301
|
+
if (this.fd !== null)
|
|
302
|
+
return;
|
|
303
|
+
mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
304
|
+
this.acquireLock();
|
|
305
|
+
try {
|
|
306
|
+
// r+ keeps whatever is already there so an interrupted transfer resumes;
|
|
307
|
+
// w+ creates it the first time.
|
|
308
|
+
this.fd = openSync(this.filePath, existsSync(this.filePath) ? "r+" : "w+");
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
this.releaseExclusiveLock();
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Work out which chunks this file already holds, by hashing them.
|
|
317
|
+
*
|
|
318
|
+
* Keeping the bytes across a restart is only half of resuming; without this
|
|
319
|
+
* the bitfield starts empty and every chunk on disk is fetched again. That is
|
|
320
|
+
* what the code did, so "resume after an interruption" was true of the file
|
|
321
|
+
* and false of the transfer.
|
|
322
|
+
*
|
|
323
|
+
* Every chunk is verified rather than inferred from the file's length. A part
|
|
324
|
+
* file is written at byte offsets, so a gap reads back as zeros and a
|
|
325
|
+
* truncated write leaves a partial chunk — neither is distinguishable from
|
|
326
|
+
* real data by size alone, and trusting either would silently corrupt the
|
|
327
|
+
* result. Hashing costs one pass over the file, which is only paid when a
|
|
328
|
+
* part file already exists.
|
|
329
|
+
*/
|
|
330
|
+
scanVerified() {
|
|
331
|
+
const map = new ChunkMap(this.manifest.totalChunks);
|
|
332
|
+
// Opening first is load-bearing. It creates a fresh part file and claims
|
|
333
|
+
// its lock before a second process can start the same destination. It also
|
|
334
|
+
// gives an empty download a real file to move at completion.
|
|
335
|
+
this.open();
|
|
336
|
+
const size = statSync(this.filePath).size;
|
|
337
|
+
if (size === 0)
|
|
338
|
+
return map;
|
|
339
|
+
for (let index = 0; index < this.manifest.totalChunks; index += 1) {
|
|
340
|
+
const length = chunkLength(index, this.manifest);
|
|
341
|
+
const offset = chunkOffset(index, this.manifest.chunkSize);
|
|
342
|
+
if (offset + length > size)
|
|
343
|
+
continue;
|
|
344
|
+
const buffer = new Uint8Array(length);
|
|
345
|
+
if (length > 0) {
|
|
346
|
+
try {
|
|
347
|
+
if (readFullySync(this.fd, buffer, offset) !== length)
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (sha256Hex(buffer) === this.manifest.chunkHashes[index])
|
|
355
|
+
map.set(index);
|
|
356
|
+
}
|
|
357
|
+
return map;
|
|
358
|
+
}
|
|
359
|
+
close() {
|
|
360
|
+
this.closeHandle();
|
|
361
|
+
this.releaseExclusiveLock();
|
|
362
|
+
}
|
|
363
|
+
/** Close the data handle while retaining ownership through final verification. */
|
|
364
|
+
closeHandle() {
|
|
365
|
+
if (this.fd !== null) {
|
|
366
|
+
closeSync(this.fd);
|
|
367
|
+
this.fd = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/** Release a download lock after its part file has been finalized or abandoned. */
|
|
371
|
+
releaseExclusiveLock() {
|
|
372
|
+
if (!this.lockPath || !this.lockToken)
|
|
373
|
+
return;
|
|
374
|
+
try {
|
|
375
|
+
const current = readFileSync(this.lockPath, "utf8").trim();
|
|
376
|
+
if (current === this.lockToken) {
|
|
377
|
+
rmSync(this.lockPath, { force: true });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// Missing or unreadable means we no longer own anything we can release.
|
|
382
|
+
}
|
|
383
|
+
this.lockToken = null;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Claim the part file, or explain who has it.
|
|
387
|
+
*
|
|
388
|
+
* Two downloads of the same file to the same folder is the one case the
|
|
389
|
+
* destination-keyed path cannot separate, and it is a genuine conflict rather
|
|
390
|
+
* than something to paper over — so fail loudly instead of interleaving
|
|
391
|
+
* writes. A lock left behind by a killed process goes stale rather than
|
|
392
|
+
* blocking that destination forever.
|
|
393
|
+
*/
|
|
394
|
+
acquireLock() {
|
|
395
|
+
if (!this.lockPath || this.lockToken)
|
|
396
|
+
return;
|
|
397
|
+
const token = `${process.pid}:${randomBytes(12).toString("hex")}`;
|
|
398
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
399
|
+
try {
|
|
400
|
+
const lockFd = openSync(this.lockPath, "wx");
|
|
401
|
+
try {
|
|
402
|
+
writeSync(lockFd, `${token}\n`, undefined, "utf8");
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
closeSync(lockFd);
|
|
406
|
+
}
|
|
407
|
+
this.lockToken = token;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
if (err.code !== "EEXIST")
|
|
412
|
+
throw err;
|
|
413
|
+
let age;
|
|
414
|
+
try {
|
|
415
|
+
age = Date.now() - statSync(this.lockPath).mtimeMs;
|
|
416
|
+
}
|
|
417
|
+
catch (statError) {
|
|
418
|
+
if (statError.code === "ENOENT")
|
|
419
|
+
continue;
|
|
420
|
+
throw statError;
|
|
421
|
+
}
|
|
422
|
+
if (age < SWARM_LOCK_STALE_MS) {
|
|
423
|
+
throw new Error(`another download of this file to this location is already running ` +
|
|
424
|
+
`(lock held for ${Math.round(age / 1000)}s at ${this.lockPath})`);
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
rmSync(this.lockPath, { force: true });
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Another contender may have replaced it. Retry the exclusive create.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
throw new Error(`could not claim download lock: ${this.lockPath}`);
|
|
435
|
+
}
|
|
436
|
+
/** Refresh the lock so a long, healthy transfer is never mistaken for stale. */
|
|
437
|
+
touchLock() {
|
|
438
|
+
if (!this.lockPath || !this.lockToken || this.fd === null)
|
|
439
|
+
return;
|
|
440
|
+
try {
|
|
441
|
+
if (readFileSync(this.lockPath, "utf8").trim() !== this.lockToken) {
|
|
442
|
+
this.lockToken = null;
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const now = new Date();
|
|
446
|
+
utimesSync(this.lockPath, now, now);
|
|
447
|
+
}
|
|
448
|
+
catch { /* the lock going missing is not worth failing a transfer over */ }
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Verify and store one chunk. Returns false when the bytes do not match the
|
|
452
|
+
* manifest hash, which means the peer that sent them is wrong or hostile —
|
|
453
|
+
* the chunk is dropped and can be re-fetched from someone else.
|
|
454
|
+
*/
|
|
455
|
+
writeChunk(index, bytes) {
|
|
456
|
+
if (index < 0 || index >= this.manifest.totalChunks)
|
|
457
|
+
return false;
|
|
458
|
+
if (bytes.byteLength !== chunkLength(index, this.manifest))
|
|
459
|
+
return false;
|
|
460
|
+
if (sha256Hex(bytes) !== this.manifest.chunkHashes[index])
|
|
461
|
+
return false;
|
|
462
|
+
this.open();
|
|
463
|
+
writeFullySync(this.fd, bytes, chunkOffset(index, this.manifest.chunkSize));
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
readChunk(index) {
|
|
467
|
+
if (index < 0 || index >= this.manifest.totalChunks)
|
|
468
|
+
return null;
|
|
469
|
+
const length = chunkLength(index, this.manifest);
|
|
470
|
+
const buffer = new Uint8Array(length);
|
|
471
|
+
if (length === 0)
|
|
472
|
+
return buffer;
|
|
473
|
+
this.open();
|
|
474
|
+
const read = readFullySync(this.fd, buffer, chunkOffset(index, this.manifest.chunkSize));
|
|
475
|
+
if (read !== length)
|
|
476
|
+
return null;
|
|
477
|
+
return buffer;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// ── Manifest construction ────────────────────────────────────────────────────
|
|
481
|
+
/** Build a manifest by hashing a file's bytes chunk by chunk. */
|
|
482
|
+
export function buildManifest(bytes, name, mimeType, chunkSize = DEFAULT_SWARM_CHUNK_SIZE) {
|
|
483
|
+
const normalizedChunkSize = normalizeChunkSize(chunkSize);
|
|
484
|
+
const size = bytes.byteLength;
|
|
485
|
+
const totalChunks = size === 0 ? 0 : Math.ceil(size / normalizedChunkSize);
|
|
486
|
+
if (totalChunks > MAX_SWARM_TOTAL_CHUNKS) {
|
|
487
|
+
throw new Error(`file requires too many chunks: ${totalChunks} > ${MAX_SWARM_TOTAL_CHUNKS}`);
|
|
488
|
+
}
|
|
489
|
+
const chunkHashes = [];
|
|
490
|
+
for (let index = 0; index < totalChunks; index += 1) {
|
|
491
|
+
const start = index * normalizedChunkSize;
|
|
492
|
+
chunkHashes.push(sha256Hex(bytes.subarray(start, Math.min(start + normalizedChunkSize, size))));
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
fileId: sha256Hex(bytes),
|
|
496
|
+
name,
|
|
497
|
+
mimeType,
|
|
498
|
+
size,
|
|
499
|
+
chunkSize: normalizedChunkSize,
|
|
500
|
+
totalChunks,
|
|
501
|
+
chunkHashes,
|
|
502
|
+
chunkHashesHash: hashChunkHashes(chunkHashes),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Build a manifest without loading the file into memory.
|
|
507
|
+
*
|
|
508
|
+
* The old path copied the whole file into a Buffer and then a Uint8Array before
|
|
509
|
+
* hashing it, and completion did the same again. That made the "large file"
|
|
510
|
+
* command consume roughly twice the file size in memory. This keeps the peak at
|
|
511
|
+
* one chunk while producing the same content and chunk digests.
|
|
512
|
+
*/
|
|
513
|
+
export function buildManifestFromFile(filePath, name, mimeType, chunkSize = DEFAULT_SWARM_CHUNK_SIZE) {
|
|
514
|
+
const normalizedChunkSize = normalizeChunkSize(chunkSize);
|
|
515
|
+
const fd = openSync(filePath, "r");
|
|
516
|
+
try {
|
|
517
|
+
const initial = fstatSync(fd);
|
|
518
|
+
if (!initial.isFile())
|
|
519
|
+
throw new Error(`not a file: ${filePath}`);
|
|
520
|
+
if (!Number.isSafeInteger(initial.size) || initial.size < 0) {
|
|
521
|
+
throw new Error(`unsupported file size: ${initial.size}`);
|
|
522
|
+
}
|
|
523
|
+
const size = initial.size;
|
|
524
|
+
const totalChunks = size === 0 ? 0 : Math.ceil(size / normalizedChunkSize);
|
|
525
|
+
if (totalChunks > MAX_SWARM_TOTAL_CHUNKS) {
|
|
526
|
+
throw new Error(`file requires too many chunks: ${totalChunks} > ${MAX_SWARM_TOTAL_CHUNKS}`);
|
|
527
|
+
}
|
|
528
|
+
const whole = createHash("sha256");
|
|
529
|
+
const chunkHashes = [];
|
|
530
|
+
const buffer = new Uint8Array(Math.min(normalizedChunkSize, Math.max(1, size)));
|
|
531
|
+
for (let index = 0; index < totalChunks; index += 1) {
|
|
532
|
+
const length = Math.min(normalizedChunkSize, size - index * normalizedChunkSize);
|
|
533
|
+
const view = length === buffer.length ? buffer : buffer.subarray(0, length);
|
|
534
|
+
const read = readFullySync(fd, view, index * normalizedChunkSize);
|
|
535
|
+
if (read !== length) {
|
|
536
|
+
throw new Error(`file changed while hashing: short read at chunk ${index}`);
|
|
537
|
+
}
|
|
538
|
+
whole.update(view);
|
|
539
|
+
chunkHashes.push(sha256Hex(view));
|
|
540
|
+
}
|
|
541
|
+
const final = fstatSync(fd);
|
|
542
|
+
if (final.size !== size || final.mtimeMs !== initial.mtimeMs) {
|
|
543
|
+
throw new Error("file changed while hashing; retry after writes finish");
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
fileId: whole.digest("hex"),
|
|
547
|
+
name,
|
|
548
|
+
mimeType,
|
|
549
|
+
size,
|
|
550
|
+
chunkSize: normalizedChunkSize,
|
|
551
|
+
totalChunks,
|
|
552
|
+
chunkHashes,
|
|
553
|
+
chunkHashesHash: hashChunkHashes(chunkHashes),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
closeSync(fd);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/** sha256 a file with bounded memory. */
|
|
561
|
+
export function sha256FileHex(filePath) {
|
|
562
|
+
const fd = openSync(filePath, "r");
|
|
563
|
+
try {
|
|
564
|
+
const hash = createHash("sha256");
|
|
565
|
+
const buffer = new Uint8Array(1024 * 1024);
|
|
566
|
+
let position = 0;
|
|
567
|
+
for (;;) {
|
|
568
|
+
const read = readSync(fd, buffer, 0, buffer.length, position);
|
|
569
|
+
if (read === 0)
|
|
570
|
+
break;
|
|
571
|
+
hash.update(buffer.subarray(0, read));
|
|
572
|
+
position += read;
|
|
573
|
+
}
|
|
574
|
+
return { sha256: hash.digest("hex"), size: position };
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
closeSync(fd);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** Stable digest for a paged chunk-hash manifest. */
|
|
581
|
+
export function hashChunkHashes(chunkHashes) {
|
|
582
|
+
const hash = createHash("sha256");
|
|
583
|
+
for (const chunkHash of chunkHashes) {
|
|
584
|
+
if (!isSha256Hex(chunkHash)) {
|
|
585
|
+
throw new Error("chunk hash must be a lowercase sha256 hex digest");
|
|
586
|
+
}
|
|
587
|
+
hash.update(Buffer.from(chunkHash, "hex"));
|
|
588
|
+
}
|
|
589
|
+
return hash.digest("hex");
|
|
590
|
+
}
|
|
591
|
+
export function toManifestSummary(manifest) {
|
|
592
|
+
const { chunkHashes: _chunkHashes, ...summary } = manifest;
|
|
593
|
+
return summary;
|
|
594
|
+
}
|
|
595
|
+
/** Return a useful reason rather than letting peer-controlled values reach I/O. */
|
|
596
|
+
export function validateManifestSummary(manifest) {
|
|
597
|
+
if (!isSha256Hex(manifest.fileId))
|
|
598
|
+
return "fileId is not a lowercase sha256 digest";
|
|
599
|
+
if (typeof manifest.name !== "string" || !manifest.name.trim())
|
|
600
|
+
return "name is empty";
|
|
601
|
+
if (manifest.name.length > 1_024 || manifest.name.includes("\0"))
|
|
602
|
+
return "name is too long or contains NUL";
|
|
603
|
+
if (typeof manifest.mimeType !== "string" || manifest.mimeType.length > 256)
|
|
604
|
+
return "mimeType is invalid";
|
|
605
|
+
if (!Number.isSafeInteger(manifest.size) || manifest.size < 0)
|
|
606
|
+
return "size is invalid";
|
|
607
|
+
if (!Number.isInteger(manifest.chunkSize) || manifest.chunkSize < 1_024 || manifest.chunkSize > MAX_SWARM_CHUNK_SIZE) {
|
|
608
|
+
return "chunkSize is invalid";
|
|
609
|
+
}
|
|
610
|
+
if (!Number.isInteger(manifest.totalChunks) || manifest.totalChunks < 0 || manifest.totalChunks > MAX_SWARM_TOTAL_CHUNKS) {
|
|
611
|
+
return "totalChunks is invalid";
|
|
612
|
+
}
|
|
613
|
+
const expectedChunks = manifest.size === 0 ? 0 : Math.ceil(manifest.size / manifest.chunkSize);
|
|
614
|
+
if (manifest.totalChunks !== expectedChunks) {
|
|
615
|
+
return `totalChunks ${manifest.totalChunks} does not match size/chunkSize (${expectedChunks})`;
|
|
616
|
+
}
|
|
617
|
+
if (!isSha256Hex(manifest.chunkHashesHash))
|
|
618
|
+
return "chunkHashesHash is invalid";
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
export function validateSwarmManifest(manifest) {
|
|
622
|
+
const summaryError = validateManifestSummary(toManifestSummary(manifest));
|
|
623
|
+
if (summaryError)
|
|
624
|
+
return summaryError;
|
|
625
|
+
if (!Array.isArray(manifest.chunkHashes) || manifest.chunkHashes.length !== manifest.totalChunks) {
|
|
626
|
+
return "chunkHashes length does not match totalChunks";
|
|
627
|
+
}
|
|
628
|
+
if (!manifest.chunkHashes.every(isSha256Hex))
|
|
629
|
+
return "chunkHashes contains an invalid digest";
|
|
630
|
+
try {
|
|
631
|
+
if (hashChunkHashes(manifest.chunkHashes) !== manifest.chunkHashesHash) {
|
|
632
|
+
return "chunkHashesHash does not match chunkHashes";
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
return "chunkHashes contains an invalid digest";
|
|
637
|
+
}
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
export function isSha256Hex(value) {
|
|
641
|
+
return typeof value === "string" && SHA256_HEX_PATTERN.test(value);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Confirm two peers are describing the same bytes before trading chunks.
|
|
645
|
+
* Without this a peer could advertise a familiar fileId while serving a
|
|
646
|
+
* different file's chunk hashes.
|
|
647
|
+
*/
|
|
648
|
+
export function manifestsAgree(a, b) {
|
|
649
|
+
if (a.fileId !== b.fileId)
|
|
650
|
+
return false;
|
|
651
|
+
if (a.size !== b.size)
|
|
652
|
+
return false;
|
|
653
|
+
if (a.chunkSize !== b.chunkSize)
|
|
654
|
+
return false;
|
|
655
|
+
if (a.totalChunks !== b.totalChunks)
|
|
656
|
+
return false;
|
|
657
|
+
if (a.chunkHashesHash !== b.chunkHashesHash)
|
|
658
|
+
return false;
|
|
659
|
+
if (a.chunkHashes.length !== b.chunkHashes.length)
|
|
660
|
+
return false;
|
|
661
|
+
return a.chunkHashes.every((hash, index) => hash === b.chunkHashes[index]);
|
|
662
|
+
}
|
|
663
|
+
export function sha256Hex(bytes) {
|
|
664
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
665
|
+
}
|
|
666
|
+
function normalizeChunkSize(chunkSize) {
|
|
667
|
+
if (!Number.isFinite(chunkSize) || chunkSize <= 0) {
|
|
668
|
+
throw new Error(`invalid chunk size: ${chunkSize}`);
|
|
669
|
+
}
|
|
670
|
+
const normalized = Math.max(1_024, Math.floor(chunkSize));
|
|
671
|
+
if (normalized > MAX_SWARM_CHUNK_SIZE) {
|
|
672
|
+
throw new Error(`chunk size ${normalized} exceeds the ${MAX_SWARM_CHUNK_SIZE}-byte safety limit`);
|
|
673
|
+
}
|
|
674
|
+
return normalized;
|
|
675
|
+
}
|
|
676
|
+
function readFullySync(fd, buffer, position) {
|
|
677
|
+
let total = 0;
|
|
678
|
+
while (total < buffer.byteLength) {
|
|
679
|
+
const read = readSync(fd, buffer, total, buffer.byteLength - total, position + total);
|
|
680
|
+
if (read === 0)
|
|
681
|
+
break;
|
|
682
|
+
total += read;
|
|
683
|
+
}
|
|
684
|
+
return total;
|
|
685
|
+
}
|
|
686
|
+
function writeFullySync(fd, bytes, position) {
|
|
687
|
+
let total = 0;
|
|
688
|
+
while (total < bytes.byteLength) {
|
|
689
|
+
const written = writeSync(fd, bytes, total, bytes.byteLength - total, position + total);
|
|
690
|
+
if (written <= 0)
|
|
691
|
+
throw new Error("short write while storing swarm chunk");
|
|
692
|
+
total += written;
|
|
693
|
+
}
|
|
694
|
+
}
|