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.
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/docs/contract.md +121 -0
- package/docs/format.md +180 -0
- package/index.js +12 -0
- package/lib/address.js +166 -0
- package/lib/blake3.cjs +15 -0
- package/lib/blake3.js +12 -0
- package/lib/collection.js +133 -0
- package/lib/destination.js +237 -0
- package/lib/fs.js +176 -0
- package/lib/nodes.js +118 -0
- package/lib/partfile.js +169 -0
- package/lib/select.js +33 -0
- package/lib/source.js +171 -0
- package/lib/swarmwire.js +841 -0
- package/lib/timers.js +21 -0
- package/lib/tree.js +326 -0
- package/lib/wire.js +95 -0
- package/package.json +94 -4
|
@@ -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
|
+
}
|