swarmwire-lib 0.0.0-stage → 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/lib/timers.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Time, injected.
3
+ *
4
+ * The transfer engine needs to give up on a peer that goes quiet, which means it needs a
5
+ * clock. Taking the clock as a dependency keeps `setTimeout` out of the engine, so the test
6
+ * harness can advance time by hand instead of tests sleeping and hoping.
7
+ */
8
+
9
+ export const systemTimers = {
10
+ /**
11
+ * @param {number} ms
12
+ * @param {Function} fn
13
+ * @returns {Function} cancel
14
+ */
15
+ setTimer (ms, fn) {
16
+ const timer = setTimeout(fn, ms)
17
+ if (typeof timer.unref === 'function') timer.unref() // never hold a process open
18
+
19
+ return () => clearTimeout(timer)
20
+ }
21
+ }
package/lib/tree.js ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The group tree: BLAKE3's own hash tree, read at the granularity of chunk groups instead of
3
+ * single 1 KiB chunks.
4
+ *
5
+ * BLAKE3 already hashes by building a tree over 1 KiB chunks. Taking the tree seriously one
6
+ * level up — treating a group of chunks as a leaf — gives exactly what a transfer needs:
7
+ *
8
+ * - the root is still the plain BLAKE3 hash of the content (`b3sum` agrees)
9
+ * - any single group can be proved against that root with ~log2(groups) hashes
10
+ * - a sender keeps only the group hashes, never the content
11
+ * - a receiver can verify a group the moment it arrives, holding nothing but the root
12
+ *
13
+ * The shape must match BLAKE3's exactly or nothing agrees with anything: the tree splits at
14
+ * the largest power of two *below* the number of leaves, which makes it lopsided for counts
15
+ * that are not powers of two. `test/unit/tree.test.js` checks the root against the plain hash
16
+ * at every size that straddles a boundary.
17
+ */
18
+
19
+ import { bao } from './blake3.js'
20
+ import b4a from 'b4a'
21
+
22
+ /** BLAKE3's chunk size. Not ours to choose. */
23
+ export const CHUNK_LEN = 1024
24
+
25
+ /** log2 of the chunks per group: 4 → 16 KiB. See docs/format.md §2. */
26
+ export const DEFAULT_GROUP_LOG = 4
27
+
28
+ /** Bytes per group. */
29
+ export function groupLength (groupLog = DEFAULT_GROUP_LOG) {
30
+ return CHUNK_LEN << groupLog
31
+ }
32
+
33
+ /** How many groups content of this size has. Empty content is one empty group. */
34
+ export function groupCount (size, groupLog = DEFAULT_GROUP_LOG) {
35
+ if (size === 0) return 1
36
+ return Math.ceil(size / groupLength(groupLog))
37
+ }
38
+
39
+ /**
40
+ * Where a group lives in the content.
41
+ *
42
+ * @returns {{offset: number, length: number}}
43
+ */
44
+ export function groupRange (index, size, groupLog = DEFAULT_GROUP_LOG) {
45
+ const len = groupLength(groupLog)
46
+ const offset = index * len
47
+ if (offset > size) throw new RangeError('group index is past the end of the content')
48
+ return { offset, length: Math.min(len, size - offset) }
49
+ }
50
+
51
+ /**
52
+ * Builds a tree one group at a time, so hashing a file never means holding it in memory.
53
+ */
54
+ export class GroupTreeBuilder {
55
+ constructor ({ groupLog = DEFAULT_GROUP_LOG } = {}) {
56
+ this.groupLog = groupLog
57
+ this.size = 0
58
+
59
+ this._hashes = []
60
+ this._full = groupLength(groupLog)
61
+ this._short = false
62
+ this._firstGroup = null // kept only until we know whether it is the only one
63
+ }
64
+
65
+ /**
66
+ * Add the next group. Every group but the last must be exactly `groupLength()` bytes.
67
+ *
68
+ * @param {Uint8Array} data
69
+ */
70
+ push (data) {
71
+ if (this._short) throw new Error('a short group must be the last one: previous group was not full')
72
+ if (data.length > this._full) throw new Error('group is longer than the group length')
73
+ if (data.length < this._full) this._short = true
74
+
75
+ const index = this._hashes.length
76
+
77
+ // A lone group is the root of the tree, which changes how BLAKE3 finalises it. We do not
78
+ // know yet whether another group is coming, so keep the first one until we do.
79
+ if (index === 0) this._firstGroup = b4a.from(data)
80
+ else this._firstGroup = null
81
+
82
+ this._hashes.push(bao.chunkGroupCV(data, index << this.groupLog, false))
83
+ this.size += data.length
84
+
85
+ return this
86
+ }
87
+
88
+ /** @returns {GroupTree} */
89
+ finalize () {
90
+ if (this._hashes.length === 0) this.push(b4a.alloc(0))
91
+
92
+ const hashes = this._hashes
93
+ if (hashes.length === 1) {
94
+ // Recompute the only group as the root.
95
+ hashes[0] = bao.chunkGroupCV(this._firstGroup, 0, true)
96
+ }
97
+
98
+ return new GroupTree(hashes, { size: this.size, groupLog: this.groupLog })
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The hash of one group: its chaining value in the tree.
104
+ *
105
+ * A peer that holds a group can always recompute this from the bytes, which is what lets a
106
+ * half-finished download prove the parts it does have.
107
+ *
108
+ * @param {Uint8Array} data
109
+ * @param {number} index
110
+ * @param {{groupLog?: number, only?: boolean}} [opts] - `only` when the group is the whole
111
+ * content, which BLAKE3 finalises differently
112
+ * @returns {Uint8Array} 32 bytes
113
+ */
114
+ export function groupHash (data, index, { groupLog = DEFAULT_GROUP_LOG, only = false } = {}) {
115
+ return bao.chunkGroupCV(data, index << groupLog, only)
116
+ }
117
+
118
+ export class GroupTree {
119
+ /**
120
+ * @param {(Uint8Array|null)[]} groupHashes - holes are allowed: a partial holder knows only
121
+ * the groups it has, and can prove a group only when the siblings it needs are present
122
+ * @param {{size: number, groupLog?: number}} opts
123
+ */
124
+ constructor (groupHashes, { size, groupLog = DEFAULT_GROUP_LOG }) {
125
+ if (groupHashes.length !== groupCount(size, groupLog)) {
126
+ throw new Error('group hashes do not match the content size')
127
+ }
128
+
129
+ this.groupHashes = groupHashes
130
+ this.size = size
131
+ this.groupLog = groupLog
132
+ }
133
+
134
+ /** Hash content held in memory. For files, stream it through GroupTreeBuilder instead. */
135
+ static fromContent (content, { groupLog = DEFAULT_GROUP_LOG } = {}) {
136
+ const builder = new GroupTreeBuilder({ groupLog })
137
+ const len = groupLength(groupLog)
138
+
139
+ for (let offset = 0; offset < content.length; offset += len) {
140
+ builder.push(content.subarray(offset, Math.min(offset + len, content.length)))
141
+ }
142
+
143
+ return builder.finalize()
144
+ }
145
+
146
+ /** Rebuild from cached group hashes — what a sender keeps next to a file. */
147
+ static fromGroupHashes (groupHashes, { size, groupLog = DEFAULT_GROUP_LOG } = {}) {
148
+ return new GroupTree(groupHashes.map((h) => b4a.from(h)), { size, groupLog })
149
+ }
150
+
151
+ get groupCount () {
152
+ return this.groupHashes.length
153
+ }
154
+
155
+ /** True when every group's hash is known, so the root can be computed. */
156
+ get complete () {
157
+ return this.groupHashes.every((hash) => hash !== null && hash !== undefined)
158
+ }
159
+
160
+ /** The content address: the plain BLAKE3 hash, hex encoded. Null if any group is missing. */
161
+ get root () {
162
+ if (!this.complete) return null
163
+ if (this.groupCount === 1) return b4a.toString(this.groupHashes[0], 'hex')
164
+
165
+ return b4a.toString(subtree(this.groupHashes, 0, this.groupCount, true), 'hex')
166
+ }
167
+
168
+ /**
169
+ * The sibling hashes proving `index` belongs to the root, nearest sibling first.
170
+ *
171
+ * Returns null when a sibling subtree has a hole in it: a partial holder can prove some of
172
+ * what it has, but not all of it, and must not pretend otherwise.
173
+ *
174
+ * @param {number} index
175
+ * @returns {Uint8Array[]|null}
176
+ */
177
+ proof (index) {
178
+ if (!Number.isInteger(index) || index < 0 || index >= this.groupCount) {
179
+ throw new RangeError('group index out of range')
180
+ }
181
+
182
+ const siblings = []
183
+ let start = 0
184
+ let end = this.groupCount
185
+
186
+ while (end - start > 1) {
187
+ const mid = start + splitPoint(end - start)
188
+ const sibling = index < mid
189
+ ? subtree(this.groupHashes, mid, end, false)
190
+ : subtree(this.groupHashes, start, mid, false)
191
+
192
+ if (sibling === null) return null
193
+ siblings.push(sibling)
194
+
195
+ if (index < mid) end = mid
196
+ else start = mid
197
+ }
198
+
199
+ return siblings.reverse()
200
+ }
201
+
202
+ /** Can this group be proved with what is known? */
203
+ canProve (index) {
204
+ return this.groupHashes[index] != null && this.proof(index) !== null
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Check that `data` really is group `index` of the content addressed by `root`.
210
+ *
211
+ * Everything a peer sends goes through here before it reaches a destination file. Returns
212
+ * false rather than throwing: a lying peer is an expected condition, not an exception.
213
+ *
214
+ * @param {object} opts
215
+ * @param {string} opts.root - 64 hex characters
216
+ * @param {number} opts.size - content size in bytes
217
+ * @param {number} opts.index - group index
218
+ * @param {Uint8Array} opts.data - the group's bytes
219
+ * @param {Uint8Array[]} opts.proof - sibling hashes, nearest first
220
+ * @param {number} [opts.groupLog]
221
+ * @returns {boolean}
222
+ */
223
+ export function verifyGroup ({ root, size, index, data, proof, groupLog = DEFAULT_GROUP_LOG }) {
224
+ const count = groupCount(size, groupLog)
225
+ if (!Number.isInteger(index) || index < 0 || index >= count) return false
226
+
227
+ // The size a group must have is fixed by the content size: a peer cannot pad or truncate.
228
+ const expected = groupRange(index, size, groupLog)
229
+ if (data.length !== expected.length) return false
230
+
231
+ const path = descend(count, index)
232
+ if (!Array.isArray(proof) || proof.length !== path.length) return false
233
+
234
+ let cv = bao.chunkGroupCV(data, index << groupLog, count === 1)
235
+
236
+ // Fold upwards, nearest sibling first; the last parent is the root.
237
+ for (let i = 0; i < proof.length; i++) {
238
+ const sibling = proof[i]
239
+ if (!sibling || sibling.length !== 32) return false
240
+
241
+ const isRoot = i === proof.length - 1
242
+ const goesLeft = path[path.length - 1 - i] === LEFT
243
+
244
+ cv = goesLeft ? bao.parentCV(cv, sibling, isRoot) : bao.parentCV(sibling, cv, isRoot)
245
+ }
246
+
247
+ return bao.constantTimeEqual(cv, b4a.from(root, 'hex'))
248
+ }
249
+
250
+ // -- tree shape --------------------------------------------------------------
251
+
252
+ const LEFT = 0
253
+ const RIGHT = 1
254
+
255
+ /** BLAKE3 splits at the largest power of two strictly below the leaf count. */
256
+ export function splitPoint (count) {
257
+ let p = 1
258
+ while (p * 2 < count) p *= 2
259
+ return p
260
+ }
261
+
262
+ /** Null when any leaf under this subtree is unknown. */
263
+ function subtree (hashes, start, end, isRoot) {
264
+ if (end - start === 1) return hashes[start] ?? null
265
+
266
+ const mid = start + splitPoint(end - start)
267
+ const left = subtree(hashes, start, mid, false)
268
+ if (left === null) return null
269
+
270
+ const right = subtree(hashes, mid, end, false)
271
+ if (right === null) return null
272
+
273
+ return bao.parentCV(left, right, isRoot)
274
+ }
275
+
276
+ /**
277
+ * The subtree ranges a proof for `index` is made of, nearest sibling first — the same order
278
+ * the proof itself uses.
279
+ *
280
+ * Knowing *which* ranges a proof's hashes describe is what lets a receiver keep them: the
281
+ * hashes it was given to check one group are exactly the ones it needs to prove that group to
282
+ * somebody else later.
283
+ *
284
+ * @param {number} count - total groups
285
+ * @param {number} index
286
+ * @returns {{start: number, end: number}[]}
287
+ */
288
+ export function siblingRanges (count, index) {
289
+ const ranges = []
290
+ let start = 0
291
+ let end = count
292
+
293
+ while (end - start > 1) {
294
+ const mid = start + splitPoint(end - start)
295
+
296
+ if (index < mid) {
297
+ ranges.push({ start: mid, end })
298
+ end = mid
299
+ } else {
300
+ ranges.push({ start, end: mid })
301
+ start = mid
302
+ }
303
+ }
304
+
305
+ return ranges.reverse()
306
+ }
307
+
308
+ /** The path from the root down to `index`: which side it is on at each level. */
309
+ function descend (count, index) {
310
+ const path = []
311
+ let start = 0
312
+ let end = count
313
+
314
+ while (end - start > 1) {
315
+ const mid = start + splitPoint(end - start)
316
+ if (index < mid) {
317
+ path.push(LEFT)
318
+ end = mid
319
+ } else {
320
+ path.push(RIGHT)
321
+ start = mid
322
+ }
323
+ }
324
+
325
+ return path
326
+ }
package/lib/wire.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The wire: what peers say to each other, and nothing more.
3
+ *
4
+ * Eight messages. A peer asks who has some content, holders say so, the asker requests groups
5
+ * one at a time, and each answer carries the proof that makes it checkable on arrival.
6
+ * Scheduling, windows and peer choice live in swarmwire.js — none of that is on the wire.
7
+ *
8
+ * Unknown message types are ignored rather than treated as errors, so adding a message later
9
+ * never breaks a peer running today's version.
10
+ */
11
+
12
+ import c from 'compact-encoding'
13
+ import Protomux from 'protomux'
14
+
15
+ export const PROTOCOL = 'swarmwire/0'
16
+ export const VERSION = 0
17
+
18
+ /** Field order is the wire format: append, never reorder. */
19
+ const encodings = {
20
+ handshake: record({ version: c.uint, groupLog: c.uint }),
21
+ want: record({ root: c.fixed32, size: c.uint }),
22
+ // `have` carries a bitfield because a peer may hold only part of the content: a download
23
+ // still in progress serves what it has, and asking it for the rest wastes a request.
24
+ have: record({ root: c.fixed32, size: c.uint, groupLog: c.uint, bitfield: c.buffer }),
25
+ request: record({ root: c.fixed32, index: c.uint }),
26
+ data: record({ root: c.fixed32, index: c.uint, proof: c.array(c.fixed32), data: c.buffer }),
27
+ unavailable: record({ root: c.fixed32 }),
28
+ // One new group, announced to whoever is interested. Sending a whole bitfield per group
29
+ // would cost kilobytes each time on a large file.
30
+ holds: record({ root: c.fixed32, index: c.uint }),
31
+ // "Not that one, not now." Silence would work too, but only after a timeout, and a group
32
+ // reserved for fifteen seconds that nobody is fetching is a stalled download.
33
+ reject: record({ root: c.fixed32, index: c.uint })
34
+ }
35
+
36
+ /** Message order is the wire format too: protomux identifies messages by position. */
37
+ const ORDER = ['handshake', 'want', 'have', 'request', 'data', 'unavailable', 'holds', 'reject']
38
+
39
+ /**
40
+ * Open the swarmwire channel on a connection.
41
+ *
42
+ * @param {object} stream - a duplex connection from the swarm
43
+ * @param {Record<string, Function>} handlers - onhandshake, onwant, onhave, onrequest,
44
+ * ondata, onunavailable, onclose; all optional
45
+ * @returns {{channel: object, send: Record<string, Function>}|null} null if the channel is
46
+ * already open on this connection
47
+ */
48
+ export function openChannel (stream, handlers = {}) {
49
+ const mux = Protomux.from(stream)
50
+
51
+ const channel = mux.createChannel({
52
+ protocol: PROTOCOL,
53
+ onclose: handlers.onclose
54
+ })
55
+
56
+ if (channel === null) return null // someone else already opened it on this stream
57
+
58
+ const messages = {}
59
+ for (const name of ORDER) {
60
+ messages[name] = channel.addMessage({
61
+ encoding: encodings[name],
62
+ onmessage: handlers['on' + name] ?? noop
63
+ })
64
+ }
65
+
66
+ channel.open()
67
+
68
+ const send = {}
69
+ for (const name of ORDER) {
70
+ send[name] = (payload) => messages[name].send(payload)
71
+ }
72
+
73
+ return { channel, send }
74
+ }
75
+
76
+ /** Build an encoding from an ordered set of fields. */
77
+ function record (fields) {
78
+ const keys = Object.keys(fields)
79
+
80
+ return {
81
+ preencode (state, message) {
82
+ for (const key of keys) fields[key].preencode(state, message[key])
83
+ },
84
+ encode (state, message) {
85
+ for (const key of keys) fields[key].encode(state, message[key])
86
+ },
87
+ decode (state) {
88
+ const message = {}
89
+ for (const key of keys) message[key] = fields[key].decode(state)
90
+ return message
91
+ }
92
+ }
93
+ }
94
+
95
+ function noop () {}
package/package.json CHANGED
@@ -1,6 +1,97 @@
1
1
  {
2
2
  "name": "swarmwire-lib",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Content-addressed, verified file transfer between filesystems over Hyperswarm. No copies, no cores, no trackers.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/tibocub/swarmwire.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/tibocub/swarmwire/issues"
14
+ },
15
+ "homepage": "https://github.com/tibocub/swarmwire#readme",
16
+ "keywords": [
17
+ "hyperswarm",
18
+ "p2p",
19
+ "file-transfer",
20
+ "content-addressed",
21
+ "bare"
22
+ ],
23
+ "files": [
24
+ "index.js",
25
+ "lib/",
26
+ "docs/format.md",
27
+ "docs/contract.md"
28
+ ],
29
+ "imports": {
30
+ "fs": {
31
+ "bare": "bare-fs",
32
+ "default": "fs"
33
+ },
34
+ "events": {
35
+ "bare": "bare-events",
36
+ "default": "events"
37
+ },
38
+ "process": {
39
+ "bare": "bare-process",
40
+ "default": "process"
41
+ },
42
+ "path": {
43
+ "bare": "bare-path",
44
+ "default": "path"
45
+ },
46
+ "os": {
47
+ "bare": "bare-os",
48
+ "default": "os"
49
+ },
50
+ "url": {
51
+ "bare": "bare-url",
52
+ "default": "url"
53
+ },
54
+ "child_process": {
55
+ "bare": "bare-subprocess",
56
+ "default": "child_process"
57
+ }
58
+ },
59
+ "scripts": {
60
+ "test": "npm run test:unit && npm run test:fs && npm run test:swarm",
61
+ "test:unit": "brittle test/unit/*.test.js",
62
+ "test:fs": "brittle test/fs/*.test.js",
63
+ "test:swarm": "brittle test/swarm/*.test.js",
64
+ "test:net": "brittle test/net/*.test.js",
65
+ "test:all": "npm test && npm run test:net && npm run test:bare && npm run test:bare:net",
66
+ "test:bare": "bare node_modules/brittle/bin/bare.js test/unit/*.test.js test/fs/*.test.js test/swarm/*.test.js",
67
+ "test:bare:net": "bare node_modules/brittle/bin/bare.js test/net/*.test.js",
68
+ "bench": "node bench/swarm.js",
69
+ "bench:net": "node bench/throughput.js",
70
+ "check:pack": "node scripts/check-pack.js"
71
+ },
72
+ "dependencies": {
73
+ "b4a": "^1.9.0",
74
+ "bare-events": "^2.9.0",
75
+ "bare-fs": "^4.8.2",
76
+ "blake3-bao": "^1.3.4",
77
+ "compact-encoding": "^3.5.2",
78
+ "hyperswarm": "^4.17.2",
79
+ "protomux": "^3.12.1",
80
+ "streamx": "^2.28.1"
81
+ },
82
+ "devDependencies": {
83
+ "@hyperswarm/testnet": "^3.1.4",
84
+ "bare": "^1.34.1",
85
+ "bare-os": "^3.6.1",
86
+ "bare-path": "^3.0.0",
87
+ "bare-process": "^4.2.4",
88
+ "bare-subprocess": "^5.0.0",
89
+ "bare-url": "^2.1.7",
90
+ "brittle": "^3.7.0",
91
+ "hyperdht": "^6.34.1"
92
+ },
93
+ "engines": {
94
+ "bare": ">=1.16",
95
+ "node": ">=20"
96
+ }
97
+ }