swarmwire-lib 0.0.0-stage → 0.1.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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A collection: a folder reduced to a list of content addresses.
3
+ *
4
+ * The manifest is itself content, so it has an address like anything else — sending a folder
5
+ * is sending one link, and the receiver fetches the manifest first and then the files it
6
+ * names. Nothing about it is mutable: change a file and you have a different manifest with a
7
+ * different address. Folders that change belong to SwarmFS, one layer up.
8
+ *
9
+ * The encoding is canonical, because the manifest's bytes are its identity: the same folder
10
+ * must produce the same address on every machine. Entries are sorted by path, there is no
11
+ * incidental whitespace, and paths are normalised before they are hashed.
12
+ *
13
+ * {"swarmwire":"collection/v0","files":[{"path":"a.txt","root":"<hex>","size":12}]}
14
+ */
15
+
16
+ import b4a from 'b4a'
17
+
18
+ export const MANIFEST_TYPE = 'collection/v0'
19
+
20
+ /** Enough for a folder of a million files; past that, something has gone wrong. */
21
+ const MAX_MANIFEST_BYTES = 64 * 1024 * 1024
22
+
23
+ /**
24
+ * @param {{path: string, root: string, size: number}[]} files
25
+ * @returns {Uint8Array} the manifest's bytes, which are what gets addressed and served
26
+ */
27
+ export function encodeManifest (files) {
28
+ const entries = files
29
+ .map((file) => ({
30
+ path: normalisePath(file.path),
31
+ root: file.root,
32
+ size: file.size
33
+ }))
34
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
35
+
36
+ for (const entry of entries) assertEntry(entry)
37
+ assertNoDuplicates(entries)
38
+
39
+ return b4a.from(JSON.stringify({ swarmwire: MANIFEST_TYPE, files: entries }), 'utf8')
40
+ }
41
+
42
+ /**
43
+ * Read a manifest, refusing anything that is not one.
44
+ *
45
+ * Everything here arrived from a stranger, so nothing is taken on trust: a path that climbs
46
+ * out of the destination directory is the obvious attack, and the content addresses are
47
+ * meaningless if the paths they are written to are not.
48
+ *
49
+ * @param {Uint8Array} bytes
50
+ * @returns {{path: string, root: string, size: number}[]}
51
+ */
52
+ export function decodeManifest (bytes) {
53
+ if (bytes.length > MAX_MANIFEST_BYTES) throw new Error('manifest is implausibly large')
54
+
55
+ let parsed
56
+ try {
57
+ parsed = JSON.parse(b4a.toString(bytes, 'utf8'))
58
+ } catch {
59
+ throw new Error('manifest is not valid JSON')
60
+ }
61
+
62
+ if (parsed?.swarmwire !== MANIFEST_TYPE) throw new Error('not a swarmwire collection')
63
+ if (!Array.isArray(parsed.files)) throw new Error('manifest has no file list')
64
+
65
+ // Paths are checked as they arrived, never normalised first. Quietly turning
66
+ // "/etc/passwd" into "etc/passwd" would write somewhere the sender did not name, and a
67
+ // manifest whose paths are not already canonical is not one we made.
68
+ const entries = parsed.files.map((file) => ({
69
+ path: file?.path,
70
+ root: file?.root,
71
+ size: file?.size
72
+ }))
73
+
74
+ for (const entry of entries) assertEntry(entry)
75
+ assertNoDuplicates(entries)
76
+
77
+ return entries
78
+ }
79
+
80
+ /** The total size of everything a manifest names. */
81
+ export function totalSize (files) {
82
+ return files.reduce((sum, file) => sum + file.size, 0)
83
+ }
84
+
85
+ /**
86
+ * Normalise a path for the manifest: forward slashes, no leading slash, no `.` segments.
87
+ *
88
+ * This does not decide whether a path is safe — `assertEntry` does that — it only makes the
89
+ * same folder produce the same bytes whichever platform packed it.
90
+ */
91
+ export function normalisePath (path) {
92
+ return String(path)
93
+ .replace(/\\/g, '/')
94
+ .split('/')
95
+ .filter((segment) => segment !== '' && segment !== '.')
96
+ .join('/')
97
+ }
98
+
99
+ /** Is this a path we are willing to write to, under a destination directory? */
100
+ export function isSafePath (path) {
101
+ if (typeof path !== 'string' || path === '') return false
102
+ if (path.length > 4096) return false
103
+ if (path !== normalisePath(path)) return false
104
+
105
+ const segments = path.split('/')
106
+ if (segments.some((segment) => segment === '..')) return false
107
+
108
+ // Windows refuses these as filenames, and a path that works on one machine and not another
109
+ // is a collection that cannot be received everywhere.
110
+ if (/[<>:"|?*\u0000-\u001f]/.test(path)) return false
111
+ if (/^[a-zA-Z]:/.test(path)) return false
112
+
113
+ return true
114
+ }
115
+
116
+ function assertEntry (entry) {
117
+ if (!isSafePath(entry.path)) throw new Error(`unsafe path in manifest: ${JSON.stringify(entry.path)}`)
118
+ if (typeof entry.root !== 'string' || !/^[0-9a-f]{64}$/.test(entry.root)) {
119
+ throw new Error(`bad root in manifest for ${entry.path}`)
120
+ }
121
+ if (!Number.isInteger(entry.size) || entry.size < 0) {
122
+ throw new Error(`bad size in manifest for ${entry.path}`)
123
+ }
124
+ }
125
+
126
+ function assertNoDuplicates (entries) {
127
+ const seen = new Set()
128
+
129
+ for (const entry of entries) {
130
+ if (seen.has(entry.path)) throw new Error(`duplicate path in manifest: ${entry.path}`)
131
+ seen.add(entry.path)
132
+ }
133
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Where a download lands — and, while it is still going, something other peers can fetch from.
3
+ *
4
+ * Three rules, and everything else follows:
5
+ *
6
+ * 1. A group is verified against the root *before* it is written. A peer that sends anything
7
+ * else is refused and gains nothing: not a byte in the file, not a bit in the bitfield.
8
+ * 2. Progress survives a crash. Every accepted group appends a 36-byte record to the part
9
+ * file beside the destination, so resuming costs nothing and rehashes nothing.
10
+ * 3. What is held can be proved. Those records carry each group's hash, which is what lets a
11
+ * half-finished download serve its groups to somebody else instead of only taking.
12
+ */
13
+
14
+ import { bao } from './blake3.js'
15
+ import b4a from 'b4a'
16
+
17
+ import { GroupTree, groupHash, verifyGroup, groupCount, groupRange, DEFAULT_GROUP_LOG } from './tree.js'
18
+ import { NodeStore } from './nodes.js'
19
+ import { PartFile, SUFFIX } from './partfile.js'
20
+ import * as fsx from './fs.js'
21
+
22
+ export const SIDECAR_SUFFIX = SUFFIX
23
+
24
+ export class Destination {
25
+ /**
26
+ * @param {string} path - where the content is being written
27
+ * @param {{root: string, size: number, groupLog?: number}} opts
28
+ * @returns {Promise<Destination>}
29
+ */
30
+ static async open (path, { root, size, groupLog = DEFAULT_GROUP_LOG }) {
31
+ const count = groupCount(size, groupLog)
32
+ const { part, nodes } = await PartFile.open(path, { root, size, groupLog })
33
+
34
+ try {
35
+ const file = await fsx.openWrite(path, { size })
36
+ return new Destination(path, file, part, { root, size, groupLog, count, nodes })
37
+ } catch (err) {
38
+ await part.close()
39
+ throw err
40
+ }
41
+ }
42
+
43
+ constructor (path, file, part, { root, size, groupLog, count, nodes }) {
44
+ this.path = path
45
+ this.root = root
46
+ this.size = size
47
+ this.groupLog = groupLog
48
+ this.groupCount = count
49
+
50
+ this._file = file
51
+ this._part = part
52
+ this._writing = new Set()
53
+ this._pending = new Set() // puts still running, which must land before anything closes
54
+ this._closed = false
55
+
56
+ this._bitfield = bao.createBitfield(count)
57
+ this._nodes = new NodeStore(count)
58
+ this._have = 0
59
+
60
+ for (const { start, end, hash, held } of nodes) {
61
+ if (start >= count || end > count || end <= start) continue
62
+
63
+ this._nodes.set(start, end, hash)
64
+
65
+ // Only a group we received is on disk. Knowing a hash from somebody else's proof says
66
+ // nothing about whether we have the bytes.
67
+ if (held && !bao.getBit(this._bitfield, start)) {
68
+ bao.setBit(this._bitfield, start)
69
+ this._have++
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Groups held so far. */
75
+ get have () {
76
+ return this._have
77
+ }
78
+
79
+ get complete () {
80
+ return this._have === this.groupCount
81
+ }
82
+
83
+ has (index) {
84
+ return bao.getBit(this._bitfield, index)
85
+ }
86
+
87
+ /** @returns {number[]} indices still wanted */
88
+ missing () {
89
+ const out = []
90
+ for (let i = 0; i < this.groupCount; i++) {
91
+ if (!this.has(i)) out.push(i)
92
+ }
93
+ return out
94
+ }
95
+
96
+ /** The bitfield as advertised to peers. */
97
+ bitfield () {
98
+ return this._bitfield
99
+ }
100
+
101
+ /**
102
+ * Offer a group. Returns false if it does not verify — in which case nothing is written and
103
+ * nothing is recorded, so a lying peer makes no progress by trying again.
104
+ *
105
+ * @param {number} index
106
+ * @param {Uint8Array} data
107
+ * @param {Uint8Array[]} proof
108
+ * @returns {Promise<boolean>}
109
+ */
110
+ async put (index, data, proof) {
111
+ if (this._closed) throw new Error('destination is closed')
112
+
113
+ const op = this._put(index, data, proof)
114
+ this._pending.add(op)
115
+
116
+ try {
117
+ return await op
118
+ } finally {
119
+ this._pending.delete(op)
120
+ }
121
+ }
122
+
123
+ async _put (index, data, proof) {
124
+ if (!Number.isInteger(index) || index < 0 || index >= this.groupCount) return false
125
+ if (this.has(index)) return true
126
+
127
+ const ok = verifyGroup({
128
+ root: this.root,
129
+ size: this.size,
130
+ index,
131
+ data,
132
+ proof,
133
+ groupLog: this.groupLog
134
+ })
135
+ if (!ok) return false
136
+
137
+ // Two peers can answer the same request at once. Both copies are valid — they verified —
138
+ // but the group must be counted once, or `have` overshoots and the download calls itself
139
+ // finished with a hole in it.
140
+ if (this._writing.has(index)) return true
141
+ this._writing.add(index)
142
+
143
+ try {
144
+ const { offset } = groupRange(index, this.size, this.groupLog)
145
+ await this._file.write(offset, data)
146
+ } finally {
147
+ this._writing.delete(index)
148
+ }
149
+
150
+ if (this.has(index)) return true
151
+
152
+ const hash = groupHash(data, index, {
153
+ groupLog: this.groupLog,
154
+ only: this.groupCount === 1
155
+ })
156
+
157
+ // Keep the proof as well as the group: those sibling hashes are exactly what we will need
158
+ // to prove this group to the next peer that asks for it.
159
+ const learned = this._nodes.learn(index, hash, proof)
160
+
161
+ bao.setBit(this._bitfield, index)
162
+ this._have++
163
+
164
+ // The group itself is marked as held; the proof hashes are knowledge, not data.
165
+ const records = learned.map((node) => ({ ...node, held: node.start === index && node.end === index + 1 }))
166
+ if (!records.some((node) => node.held)) {
167
+ records.push({ start: index, end: index + 1, hash, held: true })
168
+ }
169
+
170
+ await this._part.record(records)
171
+
172
+ return true
173
+ }
174
+
175
+ // -- serving what we already have ------------------------------------------
176
+
177
+ /** Wait for puts that started before the close. */
178
+ async _drain () {
179
+ while (this._pending.size > 0) {
180
+ await Promise.allSettled([...this._pending])
181
+ }
182
+ }
183
+
184
+ /** The finished tree. Only meaningful once every group is held. */
185
+ get tree () {
186
+ return new GroupTree(this._nodes.leaves(), { size: this.size, groupLog: this.groupLog })
187
+ }
188
+
189
+ /** Can this group be served with a proof right now? */
190
+ canProve (index) {
191
+ return this._nodes.canProve(index)
192
+ }
193
+
194
+ /** @returns {Uint8Array[]|null} null when some hash the proof needs was never learned */
195
+ proof (index) {
196
+ return this.has(index) ? this._nodes.proof(index) : null
197
+ }
198
+
199
+ /** Read a group back out of the destination file. */
200
+ async readGroup (index) {
201
+ if (!this.has(index)) return null
202
+
203
+ const { offset, length } = groupRange(index, this.size, this.groupLog)
204
+ return this._file.read(offset, length)
205
+ }
206
+
207
+ // -- lifecycle -------------------------------------------------------------
208
+
209
+ /** Complete the download: flush, drop the resume state, close. */
210
+ async finish () {
211
+ if (!this.complete) throw new Error('download is not complete')
212
+
213
+ // Closed before awaiting anything, so a group arriving now is refused cleanly rather
214
+ // than writing into a handle that is about to shut.
215
+ this._closed = true
216
+ await this._drain()
217
+
218
+ await this._file.sync()
219
+ await this._file.close()
220
+ await this._part.close()
221
+ await PartFile.remove(this.path)
222
+ }
223
+
224
+ /** Stop for now, keeping the resume state. */
225
+ async close () {
226
+ if (this._closed) return
227
+ this._closed = true
228
+
229
+ await this._drain()
230
+
231
+ try {
232
+ await this._part.close()
233
+ } finally {
234
+ await this._file.close() // never leak the handle, whatever else fails
235
+ }
236
+ }
237
+ }
package/lib/fs.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The only module that touches a filesystem.
3
+ *
4
+ * Everything else goes through here so that Bare can supply `bare-fs` without the rest of the
5
+ * library knowing. Keep this thin: open, read at an offset, write at an offset, stat, unlink.
6
+ */
7
+
8
+ import fs from 'fs'
9
+ import b4a from 'b4a'
10
+
11
+ /**
12
+ * @param {string} path
13
+ * @returns {Promise<{size: number, mtimeMs: number, directory: boolean}|null>} null if it
14
+ * does not exist
15
+ */
16
+ export async function stat (path) {
17
+ try {
18
+ const st = await fs.promises.stat(path)
19
+ return { size: st.size, mtimeMs: Math.floor(st.mtimeMs), directory: st.isDirectory() }
20
+ } catch (err) {
21
+ if (err.code === 'ENOENT') return null
22
+ throw err
23
+ }
24
+ }
25
+
26
+ /** Open a file for reading byte ranges out of it, in place. */
27
+ export async function openRead (path) {
28
+ const handle = await fs.promises.open(path, 'r')
29
+
30
+ return {
31
+ async read (offset, length) {
32
+ const buffer = b4a.alloc(length)
33
+ if (length === 0) return buffer
34
+
35
+ const { bytesRead } = await handle.read(buffer, 0, length, offset)
36
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead)
37
+ },
38
+
39
+ close () {
40
+ return handle.close()
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Open a file for writing byte ranges into it, creating it and giving it its final size up
47
+ * front so ranges can land in any order.
48
+ */
49
+ export async function openWrite (path, { size }) {
50
+ const handle = await fs.promises.open(path, (await stat(path)) ? 'r+' : 'w+')
51
+ await handle.truncate(size)
52
+
53
+ return {
54
+ async write (offset, data) {
55
+ if (data.length === 0) return
56
+ await handle.write(data, 0, data.length, offset)
57
+ },
58
+
59
+ /** Reading back matters: a partial download serves the groups it already holds. */
60
+ async read (offset, length) {
61
+ const buffer = b4a.alloc(length)
62
+ if (length === 0) return buffer
63
+
64
+ const { bytesRead } = await handle.read(buffer, 0, length, offset)
65
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead)
66
+ },
67
+
68
+ sync () {
69
+ return handle.sync()
70
+ },
71
+
72
+ close () {
73
+ return handle.close()
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Open a file for appending, readable too. Used for the append-only part file, where every
80
+ * write goes to the end and reads walk back over what was written.
81
+ */
82
+ export async function openAppend (path) {
83
+ // Not 'a+': Windows refuses to truncate a handle opened in append mode, and we need to
84
+ // truncate when the file turns out to describe different content. Track the end ourselves.
85
+ const handle = await fs.promises.open(path, (await stat(path)) ? 'r+' : 'w+')
86
+ const info = await handle.stat()
87
+
88
+ // Appends are serialised. Groups are verified concurrently, and two appends that each read
89
+ // the end of the file before either wrote would land on top of each other.
90
+ let tail = Promise.resolve()
91
+
92
+ return {
93
+ size: info.size,
94
+
95
+ async read (offset, length) {
96
+ const buffer = b4a.alloc(length)
97
+ if (length === 0) return buffer
98
+
99
+ const { bytesRead } = await handle.read(buffer, 0, length, offset)
100
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead)
101
+ },
102
+
103
+ append (data) {
104
+ const write = tail.then(async () => {
105
+ await handle.write(data, 0, data.length, this.size)
106
+ this.size += data.length
107
+ })
108
+
109
+ tail = write.catch(() => {}) // a failed append must not wedge the queue
110
+ return write
111
+ },
112
+
113
+ async truncate (size) {
114
+ await handle.truncate(size)
115
+ this.size = size
116
+ },
117
+
118
+ close () {
119
+ return handle.close()
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Every file under a directory, as paths relative to it, in a stable order.
126
+ *
127
+ * Symbolic links are skipped rather than followed: a link pointing outside the folder would
128
+ * quietly put somebody else's file into a collection.
129
+ *
130
+ * @param {string} dir
131
+ * @returns {Promise<string[]>}
132
+ */
133
+ export async function list (dir) {
134
+ const found = []
135
+
136
+ async function walk (current, prefix) {
137
+ const entries = await fs.promises.readdir(current, { withFileTypes: true })
138
+
139
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
140
+ const relative = prefix ? prefix + '/' + entry.name : entry.name
141
+
142
+ if (entry.isDirectory()) await walk(current + '/' + entry.name, relative)
143
+ else if (entry.isFile()) found.push(relative)
144
+ }
145
+ }
146
+
147
+ await walk(dir, '')
148
+ return found
149
+ }
150
+
151
+ /** Create a directory and any parents it needs. */
152
+ export function mkdir (dir) {
153
+ return fs.promises.mkdir(dir, { recursive: true })
154
+ }
155
+
156
+ /** Small whole-file helpers, for sidecars only. */
157
+ export async function readFile (path) {
158
+ try {
159
+ return await fs.promises.readFile(path)
160
+ } catch (err) {
161
+ if (err.code === 'ENOENT') return null
162
+ throw err
163
+ }
164
+ }
165
+
166
+ export function writeFile (path, data) {
167
+ return fs.promises.writeFile(path, data)
168
+ }
169
+
170
+ export async function unlink (path) {
171
+ try {
172
+ await fs.promises.unlink(path)
173
+ } catch (err) {
174
+ if (err.code !== 'ENOENT') throw err
175
+ }
176
+ }
package/lib/nodes.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Every tree node a download has learned.
3
+ *
4
+ * Verifying a group teaches you more than the group: the proof that came with it names the
5
+ * hash of each sibling subtree along the way. Keeping those is what makes a half-finished
6
+ * download useful to anyone else — it can hand over a group it holds *with the proof it was
7
+ * given*, instead of being able to prove nothing until it is nearly complete.
8
+ *
9
+ * Nodes are keyed by the range of groups they cover, so a hash learned once is shared by every
10
+ * proof that needs it.
11
+ */
12
+
13
+ import { bao } from './blake3.js'
14
+ import b4a from 'b4a'
15
+
16
+ import { siblingRanges, splitPoint } from './tree.js'
17
+
18
+ export class NodeStore {
19
+ /** @param {number} count - total groups in the content */
20
+ constructor (count) {
21
+ this.count = count
22
+ this._nodes = new Map()
23
+ }
24
+
25
+ /**
26
+ * @param {number} start - first group covered
27
+ * @param {number} end - one past the last
28
+ * @param {Uint8Array} hash
29
+ * @returns {boolean} false when this node was already known
30
+ */
31
+ set (start, end, hash) {
32
+ const key = rangeKey(start, end)
33
+ if (this._nodes.has(key)) return false
34
+
35
+ this._nodes.set(key, b4a.from(hash))
36
+ return true
37
+ }
38
+
39
+ /** The hash of a subtree, directly known or derived from its halves. Null if unknown. */
40
+ get (start, end) {
41
+ const known = this._nodes.get(rangeKey(start, end))
42
+ if (known) return known
43
+ if (end - start === 1) return null
44
+
45
+ const mid = start + splitPoint(end - start)
46
+ const left = this.get(start, mid)
47
+ if (left === null) return null
48
+
49
+ const right = this.get(mid, end)
50
+ if (right === null) return null
51
+
52
+ return bao.parentCV(left, right, false)
53
+ }
54
+
55
+ has (index) {
56
+ return this.get(index, index + 1) !== null
57
+ }
58
+
59
+ /**
60
+ * A proof that group `index` belongs to the root, built from what is known.
61
+ *
62
+ * @param {number} index
63
+ * @returns {Uint8Array[]|null} null when some sibling has never been learned
64
+ */
65
+ proof (index) {
66
+ const proof = []
67
+
68
+ for (const { start, end } of siblingRanges(this.count, index)) {
69
+ const hash = this.get(start, end)
70
+ if (hash === null) return null
71
+
72
+ proof.push(hash)
73
+ }
74
+
75
+ return proof
76
+ }
77
+
78
+ /** Can this group be handed to another peer with a proof? */
79
+ canProve (index) {
80
+ return this.has(index) && this.proof(index) !== null
81
+ }
82
+
83
+ /**
84
+ * Record everything a verified group taught us: its own hash, and the sibling hashes from
85
+ * the proof that came with it.
86
+ *
87
+ * @param {number} index
88
+ * @param {Uint8Array} hash - the group's own hash
89
+ * @param {Uint8Array[]} proof
90
+ * @returns {{start: number, end: number, hash: Uint8Array}[]} the nodes that were new
91
+ */
92
+ learn (index, hash, proof) {
93
+ const learned = []
94
+
95
+ if (this.set(index, index + 1, hash)) {
96
+ learned.push({ start: index, end: index + 1, hash })
97
+ }
98
+
99
+ const ranges = siblingRanges(this.count, index)
100
+ for (let i = 0; i < ranges.length && i < proof.length; i++) {
101
+ const { start, end } = ranges[i]
102
+ if (this.set(start, end, proof[i])) learned.push({ start, end, hash: proof[i] })
103
+ }
104
+
105
+ return learned
106
+ }
107
+
108
+ /** Leaf hashes, with holes where groups are unknown. For building a finished tree. */
109
+ leaves () {
110
+ const out = new Array(this.count).fill(null)
111
+ for (let i = 0; i < this.count; i++) out[i] = this.get(i, i + 1)
112
+ return out
113
+ }
114
+ }
115
+
116
+ function rangeKey (start, end) {
117
+ return start * 0x100000000 + end
118
+ }