OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / 9c381e4

port(0.3.0): adopt Ok test/integration suite (14 files) + REVERT F3
Adopted ok/hyper-overlay-v3 test/integration/*.test.js verbatim (imports already resolve: ../helpers.js -> our helpers, ../../lib -> our engine). They exercise the hardening we ship (paging, serve-gate/bypass, chunkmap/chunkhashes/treeresponse paging, restart-durability, GC) AND verify our deltas did not break Ok contracts. package.json test now globs test/ + test/integration/. Running them caught that F3 was WRONG: Ok test titled "fsync on a read-only handle throws on Windows: journal still persists, resume stays O(1)" documents a deliberate design — Windows FlushFileBuffers on a read-only handle throws EPERM benignly, and the journal MUST still persist or every Windows resume loses O(1) recovery. My F3 (skip journal commit on fsync error) regressed that. And F3 was redundant anyway: F1 already refuses to trust a no-contentHash journal, and the finalize whole-file hash re-verifies the gated flow — so the EIO edge F3 targeted is already closed. Reverted F3 in both _drainFlush and _flushJournalSync; kept F1 + F2. Ok transfer integration test now 51/51. Suite: 15 ours + 14 Ok integration = 29 files green in isolation. KNOWN: the multi-source peerCount>=2 assertion (both our overlay-v2-fetch and Ok fetch) is load-flaky under heavy sequential load — zero-RTT in-memory peers race; P2 window + P5 hotswap fix the real-world mechanism but cannot force fan-out when peers are truly instant. Passes in isolation; harden the assertion if CI needs it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9c381e4f4fa9ad260bea7f381307d1e26bf53734
by plankatron <fleeky@gmail.com> · 2026-07-11T19:26:03.000Z

Changed files (16) +3309 -8

lib/transfer.js +9 -7
……@@ -493,10 +493,13 @@ export class TransferManager {
493493 try { buf = this._encodeJournal(state) } catch { break }
494494 const tmp = state.journalPath + '.tmp'
495495 try {
496 // F3: let a fsync error propagate to the catch — never commit a journal that
497 // marks chunks durable when the fsync meant to persist them failed (the Linux
498 // "fsync reports a write-back error once" EIO hazard). Skip this pass instead.
499 await fs.fsync(state.fd)
496 // fsync is best-effort and swallowed on purpose: on Windows, fsync
497 // (FlushFileBuffers) on a read-only handle throws EPERM benignly, and we still
498 // want the journal to persist so resume stays O(1). The EIO write-back hazard
499 // this could mask is already closed upstream — F1 refuses to trust a journal
500 // without a contentHash, and the finalize whole-file hash re-verifies the
501 // gated flow — so skipping the commit here (former "F3") only regressed Windows.
502 try { await fs.fsync(state.fd) } catch {}
500503 await fs.writeFile(tmp, buf)
501504 if (state.fd == null) break // paused/cancelled mid-flush — don't re-create the journal
502505 await fs.rename(tmp, state.journalPath)
……@@ -512,10 +515,9 @@ export class TransferManager {
512515 if (!state.contentHash || !state.journalPath) return
513516 let buf
514517 try { buf = this._encodeJournal(state) } catch { return }
518 // fsync swallowed on purpose (Windows read-only-handle EPERM) — see _drainFlush.
519 if (state.fd != null) { try { fs.fsyncSync(state.fd) } catch {} }
515520 try {
516 // F3: fsync inside the try — on failure, skip the commit (don't claim
517 // undurable chunks) rather than swallowing the error and committing anyway.
518 if (state.fd != null) fs.fsyncSync(state.fd)
519521 const tmp = state.journalPath + '.tmp'
520522 fs.writeFileSync(tmp, buf)
521523 fs.renameSync(tmp, state.journalPath)
package.json +1 -1
……@@ -42,7 +42,7 @@
4242 "./package.json": "./package.json"
4343 },
4444 "scripts": {
45 "test": "brittle-bare test/*.test.js",
45 "test": "brittle-bare test/*.test.js test/integration/*.test.js",
4646 "test:node": "brittle-node test/*.test.js"
4747 },
4848 "engines": {
test/integration/backpressure.test.js +125 -0
……@@ -0,0 +1,125 @@
1import test from 'brittle'
2import { OverlayProtocol } from '../../lib/protocol.js'
3
4// chunkData and every other protocol the host multiplexes over the same Noise
5// stream (Corestore replication, app handshakes) share ONE socket. The seeder
6// must stop piling chunkData onto a backpressured stream (protomux MAX_BACKLOG
7// is Infinity), or sibling protocols are starved for the rest of the download.
8// These drive _onChunkNeed directly with a peer whose stream reports backpressure.
9
10const tick = () => new Promise((r) => setTimeout(r, 0))
11
12function fakeTransfer (chunkMap, bytes) {
13 return {
14 _fileIndex: { getChunkMap: async () => chunkMap, getChunkMapByHash: async () => chunkMap },
15 readChunk: (_p, off, len) => bytes.subarray(off, off + len),
16 startReceive () { return { received: new Set() } },
17 writeChunk () { return { ok: true } },
18 finalize () { return { ok: true } },
19 cancel () {}, pause () {},
20 }
21}
22
23// send() always reports backpressure (returns false + marks the stream not-drained),
24// so the seeder parks after every chunk; drain() clears it and fires 'drain'.
25function backpressuredPeer () {
26 const sent = []
27 let drained = true
28 const ls = { drain: [], close: [] }
29 const stream = {
30 on (ev, fn) { (ls[ev] ||= []).push(fn) },
31 removeListener (ev, fn) { ls[ev] = (ls[ev] || []).filter((f) => f !== fn) },
32 emit (ev, ...a) { for (const fn of [...(ls[ev] || [])]) fn(...a) },
33 }
34 const peer = {
35 mux: { stream },
36 channel: { closed: false, get drained () { return drained } },
37 authorizedServe: new Map(),
38 msgs: { chunkData: { send (m) { sent.push(m); drained = false; return false } } },
39 }
40 return {
41 peer, sent,
42 drain () { drained = true; stream.emit('drain') },
43 closeChannel () { peer.channel.closed = true; stream.emit('close') },
44 }
45}
46
47const map3 = () => [
48 { hash: 'a', offset: 0, length: 4 },
49 { hash: 'b', offset: 4, length: 4 },
50 { hash: 'c', offset: 8, length: 4 },
51]
52
53test('REGRESSION (FIX-1): seeder parks on backpressure and resumes on drain (no unbounded send)', async (t) => {
54 const proto = new OverlayProtocol({}, fakeTransfer(map3(), Buffer.alloc(12, 7)), {
55 filePaths: new Map([['content:abc', '/disk/abc']]),
56 })
57 const { peer, sent, drain } = backpressuredPeer()
58
59 const p = proto._onChunkNeed(peer, { path: 'content:abc', indices: [0, 1, 2] })
60 await tick()
61 t.is(sent.length, 1, 'sent only the first chunk, then parked on backpressure (NOT all 3)')
62 drain(); await tick()
63 t.is(sent.length, 2, 'resumed and sent the second chunk after drain')
64 drain(); await tick()
65 t.is(sent.length, 3, 'sent the third chunk after the next drain')
66 await p
67 t.pass('serve loop completed')
68})
69
70test('FIX-1: a drained stream sends the whole batch in one pass (fast-path unchanged)', async (t) => {
71 const proto = new OverlayProtocol({}, fakeTransfer(map3(), Buffer.alloc(12, 7)), {
72 filePaths: new Map([['content:abc', '/disk/abc']]),
73 })
74 const sent = []
75 const peer = {
76 mux: { stream: { on () {}, removeListener () {}, emit () {} } },
77 channel: { closed: false, drained: true },
78 authorizedServe: new Map(),
79 msgs: { chunkData: { send (m) { sent.push(m); return true } } },
80 }
81 await proto._onChunkNeed(peer, { path: 'content:abc', indices: [0, 1, 2] })
82 t.is(sent.length, 3, 'all chunks flushed when the stream is not backpressured')
83})
84
85test('FIX-1: a channel that closes while parked stops the serve loop (no send after close)', async (t) => {
86 const proto = new OverlayProtocol({}, fakeTransfer(map3(), Buffer.alloc(12, 7)), {
87 filePaths: new Map([['content:abc', '/disk/abc']]),
88 })
89 const { peer, sent, closeChannel } = backpressuredPeer()
90 const p = proto._onChunkNeed(peer, { path: 'content:abc', indices: [0, 1, 2] })
91 await tick()
92 t.is(sent.length, 1, 'first chunk sent, then parked')
93 closeChannel(); await p
94 t.is(sent.length, 1, 'no further chunks sent after the channel closed mid-transfer')
95})
96
97test('FIX-1: per-chunk serve telemetry still fires before the backpressure wait', async (t) => {
98 const served = []
99 const proto = new OverlayProtocol({}, fakeTransfer(map3(), Buffer.alloc(12, 7)), {
100 filePaths: new Map([['content:abc', '/disk/abc']]),
101 onChunkServe: (info) => served.push(info.index),
102 })
103 const { peer, drain } = backpressuredPeer()
104 const p = proto._onChunkNeed(peer, { path: 'content:abc', indices: [0, 1, 2] })
105 await tick(); drain(); await tick(); drain(); await tick()
106 await p
107 t.alike(served, [0, 1, 2], 'onChunkServe fired once per chunk despite the parking')
108})
109
110test('_onChunkNeed skips a chunk whose readChunk returns null and serves the rest', async (t) => {
111 const transfer = fakeTransfer(map3(), Buffer.alloc(12, 7))
112 transfer.readChunk = (_p, off) => (off === 4 ? null : Buffer.alloc(4, 7)) // index 1 unreadable
113 const proto = new OverlayProtocol({}, transfer, {
114 filePaths: new Map([['content:abc', '/disk/abc']]),
115 })
116 const sent = []
117 const peer = {
118 mux: { stream: { on () {}, removeListener () {}, emit () {} } },
119 channel: { closed: false, drained: true },
120 authorizedServe: new Map(),
121 msgs: { chunkData: { send (m) { sent.push(m.index); return true } } },
122 }
123 await proto._onChunkNeed(peer, { path: 'content:abc', indices: [0, 1, 2] })
124 t.alike(sent, [0, 2], 'the null chunk (index 1) is skipped; 0 and 2 still served')
125})
test/integration/chunkhashes-wire-paging.test.js +141 -0
……@@ -0,0 +1,141 @@
1// Chunk-hashes wire paging. A very large file's chunk list,
2// shipped in one `chunkHashes` Protomux frame, exceeds the Noise transport's
3// 16 MiB-1 atomic-write limit (@hyperswarm/secret-stream MAX_ATOMIC_WRITE). The
4// holder's send threw "Message is too large for an atomic write", the swarm
5// dropped the connection, and the requester stalled at 0/N bytes and timed out
6// ("no holder") — a 1.25 TB .mov never transferred a single byte. The protocol
7// now pages the list into frames each under the limit and reassembles them on
8// the receiver. The public flow (scheduler.onChunkHashes with the full list) is
9// unchanged.
10import test from 'brittle'
11import * as m from '../../lib/messages.js'
12import { OverlayProtocol } from '../../lib/protocol.js'
13
14// @hyperswarm/secret-stream rejects any frame whose payload exceeds this
15// (MAX_ATOMIC_WRITE = 256**3 - 1); the encrypted wrapper adds ABYTES on top, so
16// the real payload ceiling is a bit lower still.
17const MAX_ATOMIC_WRITE = 256 * 256 * 256 - 1
18
19function fakeTransfer () {
20 return { startReceive () { return { received: new Set() } }, writeChunk () { return { ok: true } }, finalize () { return { ok: true } }, cancel () {}, pause () {} }
21}
22
23// Exact encoded byte length of a chunkHashes frame (what hits secret-stream).
24function encodedSize (msg) {
25 const state = { start: 0, end: 0, buffer: null }
26 m.chunkHashes.preencode(state, msg)
27 return state.end
28}
29
30// A chunk list big enough that one frame blows MAX_ATOMIC_WRITE. ~37 B/entry on
31// the wire, so 500k entries ≈ ~18 MB > 16 MiB-1 — and >> MAX_CHUNKS_PER_MSG so
32// the sender must page. Distinct lengths let us assert order is preserved; the
33// hash is constant to keep the test fast (the encoder re-buffers it either way).
34function bigChunkList (count) {
35 const hash = 'a'.repeat(64)
36 const chunks = new Array(count)
37 for (let i = 0; i < count; i++) chunks[i] = { hash, length: 262144 + (i % 4096) }
38 return chunks
39}
40
41test('REGRESSION (FIX-12): a 1.25 TB-scale chunk list overflows one frame but pages stay under the limit', (t) => {
42 const chunks = bigChunkList(500000)
43
44 // The bug: the whole list as a single frame is rejected by secret-stream.
45 const oneFrame = encodedSize({ path: 'content:big', tier: 3, chunks, more: 0 })
46 t.ok(oneFrame > MAX_ATOMIC_WRITE, `single frame (${oneFrame} B) exceeds the ${MAX_ATOMIC_WRITE} B atomic-write limit`)
47
48 // The fix: _sendChunkHashes splits it into frames that each fit.
49 const proto = new OverlayProtocol({}, fakeTransfer(), {})
50 const sent = []
51 const peer = { msgs: { chunkHashes: { send: (msg) => sent.push(msg) } } }
52 proto._sendChunkHashes(peer, 'content:big', 3, chunks)
53
54 t.ok(sent.length > 1, `paged into ${sent.length} frames`)
55 for (const f of sent) {
56 // 64 KiB headroom covers the Protomux header + secret-stream ABYTES.
57 t.ok(encodedSize(f) <= MAX_ATOMIC_WRITE - 65536, `page frame ${encodedSize(f)} B fits under the limit`)
58 }
59
60 // Every page but the last is more:1; the last is more:0.
61 for (let i = 0; i < sent.length; i++) {
62 t.is(sent[i].more, i === sent.length - 1 ? 0 : 1, `page ${i} more flag`)
63 t.is(sent[i].path, 'content:big', 'path repeated on every page')
64 t.is(sent[i].tier, 3, 'tier repeated on every page')
65 }
66
67 // Pages concatenate back to the exact original list (count + order).
68 const rebuilt = sent.flatMap((f) => f.chunks)
69 t.is(rebuilt.length, chunks.length, 'no chunks lost across pages')
70 t.is(rebuilt[0].length, chunks[0].length, 'first chunk preserved')
71 t.is(rebuilt[rebuilt.length - 1].length, chunks[chunks.length - 1].length, 'last chunk preserved')
72})
73
74test('FIX-12: the receiver reassembles paged frames and dispatches the full list once', (t) => {
75 const chunks = bigChunkList(250000)
76 const proto = new OverlayProtocol({}, fakeTransfer(), {})
77
78 // Page on the send side to get realistic frames, then feed them to the receiver.
79 const sent = []
80 const sender = { msgs: { chunkHashes: { send: (msg) => sent.push(msg) } } }
81 proto._sendChunkHashes(sender, 'content:x', 3, chunks)
82 t.ok(sent.length > 1, 'multiple pages to reassemble')
83
84 // A fake scheduler stands in for the multi-source fetch — it only needs to
85 // capture the dispatched list and count progress pings.
86 let dispatched = null
87 let dispatchCount = 0
88 let pings = 0
89 proto._schedulers.set('content:x', {
90 onChunkHashes (peer, list) { dispatchCount++; dispatched = list },
91 notePageProgress () { pings++ }
92 })
93
94 const peer = { id: 'p1' }
95 for (const f of sent) proto._onChunkHashes(peer, f)
96
97 t.is(dispatchCount, 1, 'scheduler.onChunkHashes fired exactly once (on the final page)')
98 t.is(dispatched.length, chunks.length, 'full list reassembled in order')
99 t.is(dispatched[chunks.length - 1].length, chunks[chunks.length - 1].length, 'last entry intact')
100 t.is(pings, sent.length - 1, 'every non-final page re-armed the watchdog')
101 t.absent(peer._chunkHashPages.has('content:x'), 'per-path page buffer cleared after the final page')
102})
103
104test('FIX-12: a small list still ships as one frame and dispatches immediately', (t) => {
105 const chunks = [{ hash: 'b'.repeat(64), length: 16384 }, { hash: 'c'.repeat(64), length: 8192 }]
106 const proto = new OverlayProtocol({}, fakeTransfer(), {})
107
108 const sent = []
109 const sender = { msgs: { chunkHashes: { send: (msg) => sent.push(msg) } } }
110 proto._sendChunkHashes(sender, 'content:s', 0, chunks)
111 t.is(sent.length, 1, 'single frame for a small list')
112 t.is(sent[0].more, 0, 'lone frame is final')
113
114 let dispatched = null
115 proto._schedulers.set('content:s', { onChunkHashes (peer, list) { dispatched = list } })
116 const peer = { id: 'p2' }
117 proto._onChunkHashes(peer, sent[0])
118 t.is(dispatched, chunks, 'lone complete frame passes straight through (no copy)')
119})
120
121test('FIX-12: pages for two files interleaved on one channel reassemble independently', (t) => {
122 const proto = new OverlayProtocol({}, fakeTransfer(), {})
123 const peer = { id: 'p3' }
124 const got = {}
125 proto._schedulers.set('content:A', { onChunkHashes (_p, list) { got.A = list }, notePageProgress () {} })
126 proto._schedulers.set('content:B', { onChunkHashes (_p, list) { got.B = list }, notePageProgress () {} })
127
128 const a1 = { hash: 'a'.repeat(64), length: 1 }
129 const a2 = { hash: 'a'.repeat(64), length: 2 }
130 const b1 = { hash: 'b'.repeat(64), length: 3 }
131 const b2 = { hash: 'b'.repeat(64), length: 4 }
132
133 // Interleave A and B pages on the same peer/channel.
134 proto._onChunkHashes(peer, { path: 'content:A', tier: 0, chunks: [a1], more: 1 })
135 proto._onChunkHashes(peer, { path: 'content:B', tier: 0, chunks: [b1], more: 1 })
136 proto._onChunkHashes(peer, { path: 'content:A', tier: 0, chunks: [a2], more: 0 })
137 proto._onChunkHashes(peer, { path: 'content:B', tier: 0, chunks: [b2], more: 0 })
138
139 t.alike(got.A, [a1, a2], 'file A reassembled from its own pages')
140 t.alike(got.B, [b1, b2], 'file B reassembled from its own pages')
141})
test/integration/chunkmap-paging.test.js +109 -0
……@@ -0,0 +1,109 @@
1// Chunk-map paging. A very large file (e.g. 1.1 TB at tier 3,
2// ~1M chunks) produces a chunk map that, stored as one Hyperbee value, exceeds
3// Hypercore's 15 MiB MAX_SUGGESTED_BLOCK_SIZE and throws
4// `BAD_ARGUMENT: Appended block exceeds the maximum suggested block size`,
5// failing the publish after the whole-file read. FileIndex pages large maps
6// across multiple values transparently; the public API is unchanged.
7import test from 'brittle'
8import { tmpStore } from '../helpers.js'
9import { FileIndex } from '../../lib/file-index.js'
10
11const HYPERCORE_MAX_BLOCK = 15 * 1024 * 1024
12
13// Build a synthetic chunk map whose JSON encoding exceeds the Hypercore block
14// limit, without needing a real multi-TB file. ~200k entries × ~116 B ≈ 23 MB.
15function bigChunkMap (count) {
16 const chunks = new Array(count)
17 let offset = 0
18 for (let i = 0; i < count; i++) {
19 const length = 1048576
20 chunks[i] = { hash: i.toString(16).padStart(64, '0'), offset, length }
21 offset += length
22 }
23 return chunks
24}
25
26async function setup () {
27 const store = tmpStore('chunkmap-paging')
28 const index = new FileIndex(store)
29 await index.ready()
30 return index
31}
32
33test('REGRESSION (FIX-1): putChunkMap round-trips a map larger than the 15 MiB block limit', async (t) => {
34 const index = await setup()
35 const chunks = bigChunkMap(200000)
36 t.ok(JSON.stringify(chunks).length > HYPERCORE_MAX_BLOCK, 'fixture exceeds the block limit')
37
38 await index.putChunkMap('/mir/huge.bin', chunks) // threw BAD_ARGUMENT before paging
39 const got = await index.getChunkMap('/mir/huge.bin')
40
41 t.is(got.length, chunks.length, 'all chunks survive the round-trip')
42 t.alike(got[0], chunks[0], 'first chunk intact')
43 t.alike(got[got.length - 1], chunks[chunks.length - 1], 'last chunk intact')
44 t.alike(got, chunks, 'full map deep-equal (order + every field)')
45})
46
47test('FIX-1: putChunkMapByHash round-trips a map larger than the block limit', async (t) => {
48 const index = await setup()
49 const chunks = bigChunkMap(200000)
50 const oid = 'a'.repeat(64)
51
52 await index.putChunkMapByHash(oid, chunks)
53 const got = await index.getChunkMapByHash(oid)
54
55 t.is(got.length, chunks.length, 'all chunks survive the round-trip')
56 t.alike(got, chunks, 'full map deep-equal')
57})
58
59test('FIX-1: a small map is stored inline (no paging header) and round-trips', async (t) => {
60 const index = await setup()
61 const chunks = bigChunkMap(3)
62
63 await index.putChunkMap('/mir/small.bin', chunks)
64
65 const raw = await index.bee.get('chunkmap:/mir/small.bin')
66 t.ok(Array.isArray(raw.value), 'small map stored as a plain array, not a paged header')
67 t.alike(await index.getChunkMap('/mir/small.bin'), chunks, 'round-trips')
68})
69
70test('FIX-1: rewriting a paged map with a smaller one leaves no orphan pages', async (t) => {
71 const index = await setup()
72 await index.putChunkMap('/mir/shrink.bin', bigChunkMap(200000))
73 const small = bigChunkMap(2)
74 await index.putChunkMap('/mir/shrink.bin', small)
75
76 t.alike(await index.getChunkMap('/mir/shrink.bin'), small, 'returns the new small map')
77
78 let pageKeys = 0
79 for await (const e of index.bee.createReadStream({ gte: 'chunkmap:/mir/shrink.bin', lt: 'chunkmap:/mir/shrink.bin\xff' })) {
80 if (e.key.includes('\x00')) pageKeys++
81 }
82 t.is(pageKeys, 0, 'no orphan page keys remain')
83})
84
85test('FIX-1: deleting a paged map removes the header and every page', async (t) => {
86 const index = await setup()
87 await index.putChunkMap('/mir/del.bin', bigChunkMap(200000))
88 await index.delChunkMap('/mir/del.bin')
89
90 t.is(await index.getChunkMap('/mir/del.bin'), null, 'map gone')
91 t.is(await index.hasChunkMap('/mir/del.bin'), false, 'hasChunkMap false')
92
93 let leftover = 0
94 for await (const e of index.bee.createReadStream({ gte: 'chunkmap:/mir/del.bin', lt: 'chunkmap:/mir/del.bin\xff' })) {
95 leftover++
96 }
97 t.is(leftover, 0, 'no header or page keys remain')
98})
99
100test('FIX-1: a paged map missing a page reads as null (clean miss), never a truncated map', async (t) => {
101 const index = await setup()
102 const chunks = bigChunkMap(200000)
103 await index.putChunkMap('/mir/corrupt.bin', chunks)
104 // Simulate corruption: drop one interior page out from under the header.
105 await index.bee.del('chunkmap:/mir/corrupt.bin\x001')
106
107 const got = await index.getChunkMap('/mir/corrupt.bin')
108 t.is(got, null, 'incomplete paged value returns null so the caller re-chunks — not a silently short array')
109})
test/integration/fetch.test.js +140 -0
……@@ -0,0 +1,140 @@
1import test from 'brittle'
2import Protomux from 'protomux'
3import { Duplex } from 'streamx'
4import crypto from 'hypercore-crypto'
5import { tmpStore, tmpDir, fs, path } from '../helpers.js'
6import { HyperOverlay } from '../../lib/overlay.js'
7
8// In-memory duplex pair (same pattern as e2e-two-peers.js).
9function makeDuplex () {
10 let aWrite, bWrite
11 const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
12 const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
13 aWrite = (d) => a.push(d)
14 bWrite = (d) => b.push(d)
15 return [a, b]
16}
17
18async function makePeer (label) {
19 const ov = new HyperOverlay(tmpStore('ov2-' + label), {
20 namespace: 'overlay-v2',
21 destDir: tmpDir('ov2-' + label + '-dest')
22 })
23 await ov.ready()
24 return ov
25}
26
27test('V2 facade: cross-peer fetch by content hash (multi-chunk, from scratch)', async (t) => {
28 const alice = await makePeer('alice')
29 const bob = await makePeer('bob')
30 t.teardown(async () => { try { await alice.close() } catch {}; try { await bob.close() } catch {} })
31
32 const [sa, sb] = makeDuplex()
33 alice.attachProtocol(Protomux.from(sa))
34 bob.attachProtocol(Protomux.from(sb))
35 await new Promise(r => setTimeout(r, 150)) // channel handshake
36
37 // Alice writes a REAL multi-chunk file to disk and registers it. 200KB at
38 // tier 0 (avg 16KB) is ~12 chunks — exactly the bug-82 multi-chunk case the
39 // old getByHash(oid) could never serve.
40 const filesDir = tmpDir('ov2-alice-files')
41 const content = crypto.randomBytes(200 * 1024)
42 const diskPath = path.join(filesDir, 'pfp.bin')
43 fs.writeFileSync(diskPath, content)
44 const oid = crypto.data(content).toString('hex')
45
46 const reg = await alice.registerFile('/attachments/' + oid, diskPath, {
47 contentHash: oid, size: content.length
48 })
49 t.is(reg.contentHash, oid, 'registered under its content hash')
50
51 // Bob has NOTHING locally — fetch purely by content hash over the wire.
52 const got = await bob.fetchFile(oid)
53 t.ok(got, 'bob resolved the file from a peer')
54 t.is(got.local, false, 'came from a peer, not local disk')
55 t.is(Buffer.compare(fs.readFileSync(got.destPath), content), 0, 'bytes match alice’s file')
56
57 // Alice serves its own copy from the local fast path (no wire).
58 const localGot = await alice.fetchFile(oid)
59 t.ok(localGot && localGot.local, 'alice serves its own copy locally')
60 t.is(Buffer.compare(fs.readFileSync(localGot.destPath), content), 0, 'local bytes match')
61})
62
63test('V2 multi-source: chunks fetched from MULTIPLE peers in parallel', async (t) => {
64 const R = await makePeer('req')
65 const S1 = await makePeer('seed1')
66 const S2 = await makePeer('seed2')
67 t.teardown(async () => {
68 for (const ov of [R, S1, S2]) { try { await ov.close() } catch {} }
69 })
70
71 // Requester connects to BOTH seeders (two independent protomux links).
72 const [r1, s1] = makeDuplex(); R.attachProtocol(Protomux.from(r1)); S1.attachProtocol(Protomux.from(s1))
73 const [r2, s2] = makeDuplex(); R.attachProtocol(Protomux.from(r2)); S2.attachProtocol(Protomux.from(s2))
74 await new Promise(r => setTimeout(r, 250))
75
76 // Both seeders hold a FULL copy of the same content → identical chunk set
77 // (content-addressed), so either can serve any chunk.
78 const content = crypto.randomBytes(900 * 1024) // ~56 chunks at tier 0
79 const oid = crypto.data(content).toString('hex')
80 for (const [ov, label] of [[S1, 's1'], [S2, 's2']]) {
81 const dir = tmpDir('ov2-' + label + '-files')
82 const f = path.join(dir, 'big.bin')
83 fs.writeFileSync(f, content)
84 await ov.registerFile('/attachments/' + oid, f, { contentHash: oid, size: content.length })
85 }
86
87 const peers = [...R._protocol._peers.values()]
88 t.is(peers.length, 2, 'requester is connected to both seeders')
89
90 const destPath = path.join(tmpDir('ov2-req-dest'), 'out.bin')
91 const stats = await R._protocol.fetchContent(oid, peers, { destPath })
92
93 const got = fs.readFileSync(destPath)
94 t.is(Buffer.compare(got, content), 0, 'assembled file matches the seeded content')
95 t.ok(stats.peerCount >= 2,
96 'chunks were pulled from BOTH seeders (multi-source) — peerCount=' + stats.peerCount)
97})
98
99test('V2 re-seed: a fetcher becomes a seeder (multiplication)', async (t) => {
100 const S1 = await makePeer('seed')
101 const F = await makePeer('fetcher')
102 const G = await makePeer('late')
103 t.teardown(async () => {
104 for (const ov of [S1, F, G]) { try { await ov.close() } catch {} }
105 })
106
107 // S1 holds the file.
108 const content = crypto.randomBytes(150 * 1024)
109 const oid = crypto.data(content).toString('hex')
110 const dir = tmpDir('ov2-seed-files')
111 const f = path.join(dir, 'x.bin')
112 fs.writeFileSync(f, content)
113 await S1.registerFile('/attachments/' + oid, f, { contentHash: oid, size: content.length })
114
115 // F connects ONLY to S1 and fetches → should re-seed.
116 const [fa, sa] = makeDuplex()
117 F.attachProtocol(Protomux.from(fa)); S1.attachProtocol(Protomux.from(sa))
118 await new Promise(r => setTimeout(r, 200))
119 const got1 = await F.fetchFile(oid)
120 t.ok(got1 && Buffer.compare(fs.readFileSync(got1.destPath), content) === 0, 'F fetched the blob from S1')
121
122 // After fetching, F serves it from its own local copy.
123 const localOnF = await F.fetchFile(oid)
124 t.ok(localOnF && localOnF.local, 'F re-seeded the blob (now serves it locally)')
125
126 // G connects ONLY to F (never to S1) — it can only succeed if F is now a seeder.
127 const [gf, fg] = makeDuplex()
128 G.attachProtocol(Protomux.from(gf)); F.attachProtocol(Protomux.from(fg))
129 await new Promise(r => setTimeout(r, 200))
130 const got2 = await G.fetchFile(oid)
131 t.ok(got2 && !got2.local, 'G fetched from a peer (F), not local')
132 t.is(got2 && Buffer.compare(fs.readFileSync(got2.destPath), content), 0, 'G got the blob — F successfully re-seeded it')
133})
134
135test('V2 facade: fetch by unknown hash with no peers returns null', async (t) => {
136 const solo = await makePeer('solo')
137 t.teardown(async () => { try { await solo.close() } catch {} })
138 const got = await solo.fetchFile('a'.repeat(64))
139 t.is(got, null, 'no peers, not local → null (not a throw)')
140})
test/integration/file-index.test.js +482 -0
……@@ -0,0 +1,482 @@
1import test from 'brittle'
2import { tmpStore } from '../helpers.js'
3import { FileIndex } from '../../lib/file-index.js'
4
5// ── File entries ──────────────────────────────────────────────
6
7test('putFile + getFile', async (t) => {
8 const index = new FileIndex(tmpStore())
9 await index.ready()
10
11 await index.putFile('/docs/report.pdf', {
12 contentHash: 'abc123',
13 size: 524288,
14 mtime: 1712345678
15 })
16
17 const file = await index.getFile('/docs/report.pdf')
18 t.ok(file)
19 t.is(file.contentHash, 'abc123')
20 t.is(file.size, 524288)
21 t.is(file.mtime, 1712345678)
22 t.is(file.hashing, false)
23 t.is(file.executable, false)
24 t.is(file.linkname, null)
25
26 await index.close()
27})
28
29test('putFile with hashing flag', async (t) => {
30 const index = new FileIndex(tmpStore())
31 await index.ready()
32
33 await index.putFile('/large.bin', {
34 contentHash: null,
35 size: 200000000000,
36 mtime: 1712345678,
37 hashing: true
38 })
39
40 const file = await index.getFile('/large.bin')
41 t.is(file.contentHash, null)
42 t.is(file.hashing, true)
43
44 await index.close()
45})
46
47test('putFile overwrites existing entry', async (t) => {
48 const index = new FileIndex(tmpStore())
49 await index.ready()
50
51 await index.putFile('/file.txt', { contentHash: 'old', size: 100, mtime: 1000 })
52 await index.putFile('/file.txt', { contentHash: 'new', size: 200, mtime: 2000 })
53
54 const file = await index.getFile('/file.txt')
55 t.is(file.contentHash, 'new')
56 t.is(file.size, 200)
57 t.is(file.mtime, 2000)
58
59 await index.close()
60})
61
62test('getFile returns null for missing', async (t) => {
63 const index = new FileIndex(tmpStore())
64 await index.ready()
65
66 t.is(await index.getFile('/nope'), null)
67
68 await index.close()
69})
70
71test('hasFile returns true/false', async (t) => {
72 const index = new FileIndex(tmpStore())
73 await index.ready()
74
75 t.is(await index.hasFile('/missing'), false)
76 await index.putFile('/exists', { contentHash: 'x', size: 1, mtime: 1 })
77 t.is(await index.hasFile('/exists'), true)
78
79 await index.close()
80})
81
82test('delFile removes file and its chunk map', async (t) => {
83 const index = new FileIndex(tmpStore())
84 await index.ready()
85
86 await index.putFile('/delete-me', { contentHash: 'x', size: 1, mtime: 1 })
87 await index.putChunkMap('/delete-me', [{ hash: 'h1', offset: 0, length: 100 }])
88
89 t.is(await index.hasFile('/delete-me'), true)
90 t.is(await index.hasChunkMap('/delete-me'), true)
91
92 await index.delFile('/delete-me')
93
94 t.is(await index.hasFile('/delete-me'), false)
95 t.is(await index.hasChunkMap('/delete-me'), false)
96
97 await index.close()
98})
99
100test('listFiles returns all files', async (t) => {
101 const index = new FileIndex(tmpStore())
102 await index.ready()
103
104 await index.putFile('/docs/a.txt', { contentHash: 'h1', size: 100, mtime: 1 })
105 await index.putFile('/docs/b.txt', { contentHash: 'h2', size: 200, mtime: 2 })
106 await index.putFile('/images/pic.jpg', { contentHash: 'h3', size: 300, mtime: 3 })
107
108 const all = await index.listFiles()
109 t.is(all.length, 3)
110
111 const docs = await index.listFiles('/docs/')
112 t.is(docs.length, 2)
113 t.is(docs[0].path, '/docs/a.txt')
114 t.is(docs[1].path, '/docs/b.txt')
115
116 await index.close()
117})
118
119// ── Chunk maps ────────────────────────────────────────────────
120
121test('putChunkMap + getChunkMap', async (t) => {
122 const index = new FileIndex(tmpStore())
123 await index.ready()
124
125 const chunks = [
126 { hash: 'c1', offset: 0, length: 16384 },
127 { hash: 'c2', offset: 16384, length: 12288 },
128 { hash: 'c3', offset: 28672, length: 8192 }
129 ]
130
131 await index.putChunkMap('/docs/report.pdf', chunks)
132
133 const result = await index.getChunkMap('/docs/report.pdf')
134 t.ok(result)
135 t.is(result.length, 3)
136 t.is(result[0].hash, 'c1')
137 t.is(result[0].offset, 0)
138 t.is(result[0].length, 16384)
139 t.is(result[2].hash, 'c3')
140 t.is(result[2].offset, 28672)
141
142 await index.close()
143})
144
145test('getChunkMap returns null for missing', async (t) => {
146 const index = new FileIndex(tmpStore())
147 await index.ready()
148
149 t.is(await index.getChunkMap('/nope'), null)
150
151 await index.close()
152})
153
154test('hasChunkMap returns true/false', async (t) => {
155 const index = new FileIndex(tmpStore())
156 await index.ready()
157
158 t.is(await index.hasChunkMap('/nope'), false)
159 await index.putChunkMap('/file', [{ hash: 'h', offset: 0, length: 100 }])
160 t.is(await index.hasChunkMap('/file'), true)
161
162 await index.close()
163})
164
165test('delChunkMap removes chunk map only', async (t) => {
166 const index = new FileIndex(tmpStore())
167 await index.ready()
168
169 await index.putFile('/file', { contentHash: 'x', size: 1, mtime: 1 })
170 await index.putChunkMap('/file', [{ hash: 'h', offset: 0, length: 100 }])
171
172 await index.delChunkMap('/file')
173
174 t.is(await index.hasFile('/file'), true, 'file entry still exists')
175 t.is(await index.hasChunkMap('/file'), false, 'chunk map removed')
176
177 await index.close()
178})
179
180// ── Sync state ────────────────────────────────────────────────
181
182test('putSyncState + getSyncState', async (t) => {
183 const index = new FileIndex(tmpStore())
184 await index.ready()
185
186 await index.putSyncState('peer1', '/docs/report.pdf', {
187 lastSeq: 42,
188 lastHash: 'abc123'
189 })
190
191 const state = await index.getSyncState('peer1', '/docs/report.pdf')
192 t.ok(state)
193 t.is(state.lastSeq, 42)
194 t.is(state.lastHash, 'abc123')
195
196 await index.close()
197})
198
199test('getSyncState returns null for missing', async (t) => {
200 const index = new FileIndex(tmpStore())
201 await index.ready()
202
203 t.is(await index.getSyncState('peer1', '/nope'), null)
204
205 await index.close()
206})
207
208test('listSyncStates returns all states for a peer', async (t) => {
209 const index = new FileIndex(tmpStore())
210 await index.ready()
211
212 await index.putSyncState('peer1', '/a.txt', { lastSeq: 1, lastHash: 'h1' })
213 await index.putSyncState('peer1', '/b.txt', { lastSeq: 2, lastHash: 'h2' })
214 await index.putSyncState('peer2', '/a.txt', { lastSeq: 5, lastHash: 'h5' })
215
216 const peer1States = await index.listSyncStates('peer1')
217 t.is(peer1States.length, 2)
218 t.is(peer1States[0].path, '/a.txt')
219 t.is(peer1States[0].lastSeq, 1)
220 t.is(peer1States[1].path, '/b.txt')
221
222 const peer2States = await index.listSyncStates('peer2')
223 t.is(peer2States.length, 1)
224
225 await index.close()
226})
227
228test('delSyncState removes specific state', async (t) => {
229 const index = new FileIndex(tmpStore())
230 await index.ready()
231
232 await index.putSyncState('peer1', '/a.txt', { lastSeq: 1, lastHash: 'h1' })
233 await index.putSyncState('peer1', '/b.txt', { lastSeq: 2, lastHash: 'h2' })
234
235 await index.delSyncState('peer1', '/a.txt')
236
237 t.is(await index.getSyncState('peer1', '/a.txt'), null)
238 t.ok(await index.getSyncState('peer1', '/b.txt'))
239
240 await index.close()
241})
242
243// ── Sync config ───────────────────────────────────────────────
244
245test('putSyncConfig + getSyncConfig', async (t) => {
246 const index = new FileIndex(tmpStore())
247 await index.ready()
248
249 const config = {
250 folders: [
251 { path: '/docs/', strategy: 'prompt', quota: null, peers: [] },
252 { path: '/media/', strategy: 'newest-wins', quota: '50GB', peers: ['peer1'] }
253 ]
254 }
255
256 await index.putSyncConfig(config)
257
258 const result = await index.getSyncConfig()
259 t.ok(result)
260 t.is(result.folders.length, 2)
261 t.is(result.folders[0].path, '/docs/')
262 t.is(result.folders[0].strategy, 'prompt')
263 t.is(result.folders[1].quota, '50GB')
264
265 await index.close()
266})
267
268test('getSyncConfig returns null when not set', async (t) => {
269 const index = new FileIndex(tmpStore())
270 await index.ready()
271
272 t.is(await index.getSyncConfig(), null)
273
274 await index.close()
275})
276
277// ── Stats ─────────────────────────────────────────────────────
278
279test('stats returns correct counts', async (t) => {
280 const index = new FileIndex(tmpStore())
281 await index.ready()
282
283 await index.putFile('/a', { contentHash: 'h1', size: 100, mtime: 1 })
284 await index.putFile('/b', { contentHash: 'h2', size: 200, mtime: 2 })
285 await index.putChunkMap('/a', [{ hash: 'c1', offset: 0, length: 100 }])
286 await index.putSyncState('peer1', '/a', { lastSeq: 1, lastHash: 'h1' })
287
288 const s = await index.stats()
289 t.is(s.fileCount, 2)
290 t.is(s.chunkMapCount, 1)
291 t.is(s.syncStateCount, 1)
292
293 await index.close()
294})
295
296// ── No blob storage ───────────────────────────────────────────
297
298test('file index stores no content data', async (t) => {
299 const index = new FileIndex(tmpStore())
300 await index.ready()
301
302 await index.putFile('/file.bin', {
303 contentHash: 'abc123',
304 size: 1048576,
305 mtime: 1712345678
306 })
307
308 await index.putChunkMap('/file.bin', [
309 { hash: 'c1', offset: 0, length: 16384 },
310 { hash: 'c2', offset: 16384, length: 16384 }
311 ])
312
313 // Verify: no 'data' field anywhere
314 const file = await index.getFile('/file.bin')
315 t.absent(file.data, 'no data field on file entry')
316
317 const chunks = await index.getChunkMap('/file.bin')
318 for (const c of chunks) {
319 t.absent(c.data, 'no data field on chunk map entry')
320 }
321
322 await index.close()
323})
324
325// ── Tree entries ──────────────────────────────────────────────
326
327function fakeHash (byte) {
328 return Buffer.alloc(32, byte).toString('hex')
329}
330
331function makeTree (size, entries = []) {
332 return { entries, size }
333}
334
335test('putTree + getTree + hasTree', async (t) => {
336 const index = new FileIndex(tmpStore())
337 await index.ready()
338
339 const h = fakeHash(0xaa)
340 const tree = makeTree(100, [
341 { kind: 0, exec: 0, name: 'foo.js', childHash: fakeHash(0x01), size: 50 },
342 { kind: 0, exec: 0, name: 'bar.js', childHash: fakeHash(0x02), size: 50 }
343 ])
344
345 t.absent(await index.hasTree(h))
346 await index.putTree(h, tree)
347 t.ok(await index.hasTree(h))
348
349 const got = await index.getTree(h)
350 t.ok(got)
351 t.is(got.size, 100)
352 t.is(got.entries.length, 2)
353 t.is(got.entries[0].name, 'foo.js')
354
355 await index.close()
356})
357
358test('getTree returns null for missing hash', async (t) => {
359 const index = new FileIndex(tmpStore())
360 await index.ready()
361 t.is(await index.getTree(fakeHash(0xff)), null)
362 await index.close()
363})
364
365test('delTree removes the tree', async (t) => {
366 const index = new FileIndex(tmpStore())
367 await index.ready()
368 const h = fakeHash(0x11)
369 await index.putTree(h, makeTree(0))
370 t.ok(await index.hasTree(h))
371 await index.delTree(h)
372 t.absent(await index.hasTree(h))
373 await index.close()
374})
375
376test('putTree is idempotent (same hash overwrites with same content)', async (t) => {
377 const index = new FileIndex(tmpStore())
378 await index.ready()
379 const h = fakeHash(0x22)
380 await index.putTree(h, makeTree(50, []))
381 await index.putTree(h, makeTree(50, []))
382 t.ok(await index.hasTree(h))
383 await index.close()
384})
385
386test('putTreePath + getTreePath', async (t) => {
387 const index = new FileIndex(tmpStore())
388 await index.ready()
389
390 await index.putTreePath('/node_modules/hyper-svc', fakeHash(0x33))
391 t.is(await index.getTreePath('/node_modules/hyper-svc'), fakeHash(0x33))
392 t.is(await index.getTreePath('/does/not/exist'), null)
393
394 // Rewriting the path points to a new hash
395 await index.putTreePath('/node_modules/hyper-svc', fakeHash(0x44))
396 t.is(await index.getTreePath('/node_modules/hyper-svc'), fakeHash(0x44))
397
398 await index.close()
399})
400
401test('delTreePath removes path mapping without touching the tree', async (t) => {
402 const index = new FileIndex(tmpStore())
403 await index.ready()
404
405 const h = fakeHash(0x55)
406 await index.putTree(h, makeTree(10))
407 await index.putTreePath('/some/path', h)
408
409 await index.delTreePath('/some/path')
410 t.is(await index.getTreePath('/some/path'), null)
411 t.ok(await index.hasTree(h), 'tree itself still present after path deletion')
412
413 await index.close()
414})
415
416test('listTrees enumerates stored trees', async (t) => {
417 const index = new FileIndex(tmpStore())
418 await index.ready()
419
420 await index.putTree(fakeHash(0x01), makeTree(100, [{ kind: 0, exec: 0, name: 'a', childHash: fakeHash(0x10), size: 10 }]))
421 await index.putTree(fakeHash(0x02), makeTree(200, [
422 { kind: 0, exec: 0, name: 'b', childHash: fakeHash(0x20), size: 20 },
423 { kind: 0, exec: 0, name: 'c', childHash: fakeHash(0x30), size: 30 }
424 ]))
425
426 const all = await index.listTrees()
427 t.is(all.length, 2)
428
429 // Sorted by hash (Hyperbee key order)
430 t.is(all[0].hash, fakeHash(0x01))
431 t.is(all[0].entryCount, 1)
432 t.is(all[0].size, 100)
433 t.is(all[1].hash, fakeHash(0x02))
434 t.is(all[1].entryCount, 2)
435
436 const limited = await index.listTrees({ limit: 1 })
437 t.is(limited.length, 1)
438
439 await index.close()
440})
441
442test('stats includes tree and treePath counts', async (t) => {
443 const index = new FileIndex(tmpStore())
444 await index.ready()
445
446 await index.putFile('/a.txt', { contentHash: 'f1', size: 10, mtime: 1 })
447 await index.putTree(fakeHash(0x01), makeTree(10))
448 await index.putTree(fakeHash(0x02), makeTree(20))
449 await index.putTreePath('/a', fakeHash(0x01))
450
451 const s = await index.stats()
452 t.is(s.fileCount, 1)
453 t.is(s.treeCount, 2)
454 t.is(s.treePathCount, 1)
455
456 await index.close()
457})
458
459test('tree storage does not interfere with existing file entries', async (t) => {
460 const index = new FileIndex(tmpStore())
461 await index.ready()
462
463 // File with name that could plausibly overlap with a tree path
464 await index.putFile('/foo', { contentHash: 'h1', size: 100, mtime: 1000 })
465 // Tree at a hash — different key namespace
466 await index.putTree(fakeHash(0x88), makeTree(50))
467 // Treepath for the same path
468 await index.putTreePath('/foo', fakeHash(0x88))
469
470 // All three coexist
471 const file = await index.getFile('/foo')
472 t.ok(file)
473 t.is(file.contentHash, 'h1')
474
475 const tree = await index.getTree(fakeHash(0x88))
476 t.ok(tree)
477
478 const tp = await index.getTreePath('/foo')
479 t.is(tp, fakeHash(0x88))
480
481 await index.close()
482})
test/integration/mirror-spool.test.js +66 -0
……@@ -0,0 +1,66 @@
1import test from 'brittle'
2import Protomux from 'protomux'
3import crypto from 'hypercore-crypto'
4import { Duplex } from 'streamx'
5import { tmpStore, tmpDir, fs, path } from '../helpers.js'
6import { HyperOverlay } from '../../lib/overlay.js'
7
8// Phase 2 mirror primitive: a forge backend (no working tree) replicates a
9// repo's large files by FETCHING each by oid and storing it CONTENT-ADDRESSED at
10// <mirrorSpool>/<oid>. Combined with the 0.2.8 content-addressed serve fallback
11// (<syncBaseDir>/<oid>), the backend then durably re-serves them — becoming a
12// second source. This is the building block the pgh-sidecar overlay mirror uses.
13
14function makeDuplex () {
15 let aWrite, bWrite
16 const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
17 const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
18 aWrite = (d) => a.push(d)
19 bWrite = (d) => b.push(d)
20 return [a, b]
21}
22const settle = (ms = 400) => new Promise(r => setTimeout(r, ms))
23
24test('mirror fetch stores the blob content-addressed at <mirrorSpool>/<oid> and re-serves it', async (t) => {
25 // Publisher serves from its content-addressed spool.
26 const pubSpool = tmpDir('ms-pub-spool')
27 const content = crypto.randomBytes(256 * 1024)
28 const oid = crypto.data(content).toString('hex')
29 fs.writeFileSync(path.join(pubSpool, oid), content)
30 const pub = new HyperOverlay(tmpStore('ms-pub'), { namespace: 'overlay-v2', syncBaseDirs: [pubSpool], destDir: tmpDir('ms-pub-dest') })
31
32 // Mirror has its own content-addressed mirror-spool (no working tree).
33 const mirrorSpool = tmpDir('ms-mirror-spool')
34 const mir = new HyperOverlay(tmpStore('ms-mir'), { namespace: 'overlay-v2', syncBaseDirs: [mirrorSpool], destDir: tmpDir('ms-mir-dest') })
35
36 await pub.ready(); await mir.ready()
37 t.teardown(async () => { try { await pub.close() } catch {}; try { await mir.close() } catch {} })
38
39 const [sa, sb] = makeDuplex()
40 pub.attachProtocol(Protomux.from(sa))
41 mir.attachProtocol(Protomux.from(sb))
42 await settle()
43
44 const got = await mir.fetchFile(oid, { mirrorSpool, timeout: 6000 })
45 t.ok(got, 'mirror fetched the file from the publisher')
46 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), content), 0, 'fetched bytes match')
47
48 // KEY: the blob is stored content-addressed in the mirror-spool (durable,
49 // restart-survivable, re-servable via the 0.2.8 <syncBaseDir>/<oid> fallback).
50 const spoolPath = path.join(mirrorSpool, oid)
51 t.ok(fs.existsSync(spoolPath), 'blob stored at <mirrorSpool>/<oid>')
52 if (fs.existsSync(spoolPath)) t.is(Buffer.compare(fs.readFileSync(spoolPath), content), 0, 'spool bytes match')
53
54 // The mirror is now a SECOND source: a cold third node (only the mirror's
55 // spool, empty maps) re-serves it to a fresh consumer.
56 const reserver = new HyperOverlay(tmpStore('ms-re'), { namespace: 'overlay-v2', syncBaseDirs: [mirrorSpool], destDir: tmpDir('ms-re-dest') })
57 const con2 = new HyperOverlay(tmpStore('ms-con2'), { namespace: 'overlay-v2', destDir: tmpDir('ms-con2-dest') })
58 await reserver.ready(); await con2.ready()
59 t.teardown(async () => { try { await reserver.close() } catch {}; try { await con2.close() } catch {} })
60 const [sc, sd] = makeDuplex()
61 reserver.attachProtocol(Protomux.from(sc))
62 con2.attachProtocol(Protomux.from(sd))
63 await settle()
64 const got2 = await con2.fetchFile(oid, { timeout: 6000 })
65 t.ok(got2, 'a cold re-server (mirror-spool only) serves the mirrored blob onward')
66})
test/integration/protocol.test.js +172 -0
……@@ -0,0 +1,172 @@
1import test from 'brittle'
2import fs from 'bare-fs'
3import os from 'bare-os'
4import path from 'bare-path'
5import { OverlayProtocol } from '../../lib/protocol.js'
6
7// The protocol's fetchContent owns the per-contentHash scheduler: the cancel-before-
8// scheduler window (#1b) and the same-hash join (#2). A minimal transfer stub is
9// enough — fetchContent only hands it to the ChunkScheduler.
10function fakeTransfer () {
11 return { startReceive () { return { received: new Set() } }, writeChunk () { return { ok: true } }, finalize () { return { ok: true } }, cancel () {}, pause () {} }
12}
13const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'p2-'))
14
15test('#1b: cancelContent before the scheduler exists cancels the fetch at creation', async (t) => {
16 const proto = new OverlayProtocol({}, fakeTransfer(), {})
17 proto.cancelContent('zzz', { discardPartial: true }) // no scheduler yet → recorded in _cancelPending
18 await t.exception(proto.fetchContent('zzz', [], { destPath: path.join(tmp(), 'z'), timeout: 200 }), /cancelled/,
19 'fetchContent honors the pending cancel and rejects ECANCELLED — no requestContent sent')
20})
21
22test('#2: a concurrent same-hash fetch joins the in-flight one and copies the result', async (t) => {
23 const proto = new OverlayProtocol({}, fakeTransfer(), {})
24 const dir = tmp()
25 const a = path.join(dir, 'a'); const b = path.join(dir, 'b')
26 fs.writeFileSync(a, 'shared bytes') // the leader's assembled file
27
28 const first = proto.fetchContent('abc', [], { destPath: a, timeout: 200 })
29 const second = proto.fetchContent('abc', [], { destPath: b, timeout: 200 }) // joins (no 'already fetching' reject)
30 t.is(proto._schedulers.size, 1, 'the join did not create a second scheduler')
31
32 // Drive the leader to completion: an empty chunk list finalizes immediately.
33 proto._onChunkHashes({ id: 'p' }, { path: 'content:abc', chunks: [] })
34 await first
35 await second
36 t.is(fs.readFileSync(b).toString(), 'shared bytes', 'the joiner received a copy of the leader\'s verified bytes')
37})
38
39test('#1b: clearCancelPending drops a stale marker so the next same-hash fetch is not cancelled', async (t) => {
40 const proto = new OverlayProtocol({}, fakeTransfer(), {})
41 proto.cancelContent('ghi', { discardPartial: true }) // marks _cancelPending (no scheduler yet)
42 proto.clearCancelPending('ghi') // fetchFile's no-peer abandon clears it
43 const f = proto.fetchContent('ghi', [], { destPath: path.join(tmp(), 'g'), timeout: 200 })
44 f.catch(() => {})
45 t.is(proto._schedulers.size, 1, 'a normal scheduler exists — the stale cancel did not fire')
46})
47
48test('#2: a joiner re-issues its own fetch when the leader was cancelled', async (t) => {
49 const proto = new OverlayProtocol({}, fakeTransfer(), {})
50 const dir = tmp()
51 const first = proto.fetchContent('def', [], { destPath: path.join(dir, 'a'), timeout: 200 })
52 first.catch(() => {})
53 const second = proto.fetchContent('def', [], { destPath: path.join(dir, 'b'), timeout: 200 }) // joins
54 second.catch(() => {})
55 proto.cancelContent('def', { discardPartial: true }) // cancel the leader → ECANCELLED
56 // The joiner's ECANCELLED handler re-issues, creating a fresh scheduler for the same hash.
57 await new Promise((r) => setTimeout(r, 20))
58 t.is(proto._schedulers.size, 1, 'a fresh scheduler exists for the re-issued joiner (leader\'s was removed)')
59})
60
61// ── transfer-control (message 12): downloader→holder pause/stop signal ─────────
62
63function fakePeer (sent) {
64 return { msgs: { transferControl: { send: (m) => sent.push(m) } }, authorizedServe: new Map() }
65}
66
67// cancelContent only signals when a scheduler exists (an active fetch); seed one.
68function seedScheduler (proto, contentHash) {
69 proto._schedulers.set('content:' + contentHash, { destPath: '/x', cancel () {} })
70}
71
72test('REGRESSION (FIX-1): cancelContent pause broadcasts transferControl PAUSED', (t) => {
73 const proto = new OverlayProtocol({}, fakeTransfer(), {})
74 const sent = []
75 proto._peers.set({}, fakePeer(sent))
76 seedScheduler(proto, 'abc')
77 proto.cancelContent('abc', { discardPartial: false })
78 t.alike(sent, [{ contentHash: 'abc', state: 0 }], 'one PAUSED (state 0) sent before local teardown')
79})
80
81test('REGRESSION (FIX-2): cancelContent stop broadcasts transferControl STOPPED', (t) => {
82 const proto = new OverlayProtocol({}, fakeTransfer(), {})
83 const sent = []
84 proto._peers.set({}, fakePeer(sent))
85 seedScheduler(proto, 'abc')
86 proto.cancelContent('abc', { discardPartial: true })
87 t.alike(sent, [{ contentHash: 'abc', state: 1 }], 'one STOPPED (state 1) sent before local teardown')
88})
89
90test('cancelContent with signal:false (supersede) sends nothing', (t) => {
91 const proto = new OverlayProtocol({}, fakeTransfer(), {})
92 const sent = []
93 proto._peers.set({}, fakePeer(sent))
94 seedScheduler(proto, 'abc')
95 proto.cancelContent('abc', { discardPartial: true, signal: false })
96 t.is(sent.length, 0, 'a supersede restart suppresses the transfer-control broadcast')
97})
98
99test('cancelContent without a scheduler (pre-fetch cancel) sends nothing', (t) => {
100 const proto = new OverlayProtocol({}, fakeTransfer(), {})
101 const sent = []
102 proto._peers.set({}, fakePeer(sent))
103 proto.cancelContent('abc', { discardPartial: false }) // no scheduler → _cancelPending path
104 t.is(sent.length, 0, 'no holder authorized us yet, so nothing is broadcast')
105})
106
107test('sendStopControl broadcasts STOPPED without a scheduler (discard-after-pause path)', (t) => {
108 const proto = new OverlayProtocol({}, fakeTransfer(), {})
109 const sent = []
110 proto._peers.set({}, fakePeer(sent))
111 proto.sendStopControl('abc')
112 t.alike(sent, [{ contentHash: 'abc', state: 1 }], 'STOPPED sent directly, no scheduler required')
113})
114
115test('_onTransferControl maps to onServeControl using the authenticated authorizedServe identity', (t) => {
116 const calls = []
117 const proto = new OverlayProtocol({}, fakeTransfer(), { onServeControl: (info) => calls.push(info) })
118 const peer = { authorizedServe: new Map([['content:abc', 'peerProfileKey']]) }
119 proto._onTransferControl(peer, { contentHash: 'abc', state: 1 })
120 proto._onTransferControl(peer, { contentHash: 'abc', state: 0 })
121 t.is(calls.length, 2)
122 t.alike(calls[0], { path: 'content:abc', peer, from: 'peerProfileKey', state: 'stopped' })
123 t.alike(calls[1], { path: 'content:abc', peer, from: 'peerProfileKey', state: 'paused' })
124})
125
126test('_onTransferControl is a no-op for a hash the peer was never authorized to fetch (anti-spoof)', (t) => {
127 const calls = []
128 const proto = new OverlayProtocol({}, fakeTransfer(), { onServeControl: (info) => calls.push(info) })
129 proto._onTransferControl({ authorizedServe: new Map() }, { contentHash: 'zzz', state: 1 })
130 t.is(calls.length, 0, 'no ledger callback without an authenticated serve record')
131})
132
133test('_sendTransferControl tolerates a peer that predates slot 12 (no throw)', (t) => {
134 const proto = new OverlayProtocol({}, fakeTransfer(), {})
135 proto._peers.set({}, { msgs: {}, authorizedServe: new Map() }) // old peer: no transferControl slot
136 seedScheduler(proto, 'abc')
137 try { proto.cancelContent('abc', { discardPartial: false }); t.pass('cancelContent did not throw') }
138 catch (err) { t.fail('threw: ' + err.message) }
139})
140
141// ── transfer-progress (message 13): downloader→holder resume have-baseline ──────
142
143test('sendTransferProgress broadcasts the have-baseline to every connected holder', (t) => {
144 const proto = new OverlayProtocol({}, fakeTransfer(), {})
145 const sent = []
146 proto._peers.set({}, { msgs: { transferProgress: { send: (m) => sent.push(m) } }, authorizedServe: new Map() })
147 proto.sendTransferProgress('abc', 700)
148 t.alike(sent, [{ contentHash: 'abc', have: 700 }], 'have-baseline broadcast to the holder')
149})
150
151test('_onTransferProgress maps to onServeProgress using the authenticated authorizedServe identity', (t) => {
152 const calls = []
153 const proto = new OverlayProtocol({}, fakeTransfer(), { onServeProgress: (info) => calls.push(info) })
154 const peer = { authorizedServe: new Map([['content:abc', 'peerProfileKey']]) }
155 proto._onTransferProgress(peer, { contentHash: 'abc', have: 700 })
156 t.is(calls.length, 1)
157 t.alike(calls[0], { path: 'content:abc', peer, from: 'peerProfileKey', have: 700 })
158})
159
160test('_onTransferProgress is a no-op for a hash the peer was never authorized to fetch (anti-spoof)', (t) => {
161 const calls = []
162 const proto = new OverlayProtocol({}, fakeTransfer(), { onServeProgress: (info) => calls.push(info) })
163 proto._onTransferProgress({ authorizedServe: new Map() }, { contentHash: 'zzz', have: 700 })
164 t.is(calls.length, 0, 'no ledger callback without an authenticated serve record')
165})
166
167test('sendTransferProgress tolerates a peer that predates slot 13 (no throw)', (t) => {
168 const proto = new OverlayProtocol({}, fakeTransfer(), {})
169 proto._peers.set({}, { msgs: {}, authorizedServe: new Map() }) // old peer: no transferProgress slot
170 try { proto.sendTransferProgress('abc', 700); t.pass('sendTransferProgress did not throw') }
171 catch (err) { t.fail('threw: ' + err.message) }
172})
test/integration/restart-durability.test.js +55 -0
……@@ -0,0 +1,55 @@
1// Ported from hyper-overlay upstream test/overlay-v2-restart-durability.test.js
2// (6cac8ee). Body verbatim; only import paths retargeted.
3// Exercises a cold publisher serving by content hash with NO serveAuthorizer
4// wired — proving the serve gate is a no-op when absent (backward-compatible).
5import test from 'brittle'
6import Protomux from 'protomux'
7import crypto from 'hypercore-crypto'
8import { Duplex } from 'streamx'
9import { tmpStore, tmpDir, fs, path } from '../helpers.js'
10import { HyperOverlay } from '../../lib/overlay.js'
11
12// Phase 2: a forge serves hyper-tps' large files (NOT stored in git) by content
13// hash over hyper-overlay — the deliberate "no hyperblobs" design. The canonical
14// bytes live at <spool>/<oid> (content-addressed; pear-git-v3 router.js spools an
15// upload to spool/<oid>, where oid IS the blake2b content hash). After a daemon
16// restart the in-memory _contentHashPaths map is empty and FileIndex persists no
17// disk path, so the publisher must resolve a content request straight from the
18// content-addressed spool. This reproduces a COLD publisher (bytes in the spool,
19// empty maps, never registerFile'd) and asserts it still serves — exactly the
20// post-restart condition that currently breaks cross-machine LFS serving.
21
22function makeDuplex () {
23 let aWrite, bWrite
24 const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
25 const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
26 aWrite = (d) => a.push(d)
27 bWrite = (d) => b.push(d)
28 return [a, b]
29}
30
31const settle = (ms = 400) => new Promise(r => setTimeout(r, ms))
32
33test('RESTART DURABILITY: a cold publisher serves by content hash from the content-addressed spool', async (t) => {
34 const spool = tmpDir('ov2-rd-spool')
35 const content = crypto.randomBytes(300 * 1024)
36 const oid = crypto.data(content).toString('hex')
37 fs.writeFileSync(path.join(spool, oid), content) // canonical bytes at <spool>/<oid>
38
39 // Cold publisher: fresh store, EMPTY in-memory maps, NEVER registerFile'd —
40 // exactly the post-restart state (the spool persists on disk; the maps do not).
41 const pub = new HyperOverlay(tmpStore('ov2-rd-pub'), { namespace: 'overlay-v2', syncBaseDirs: [spool], destDir: tmpDir('ov2-rd-pub-dest') })
42 const con = new HyperOverlay(tmpStore('ov2-rd-con'), { namespace: 'overlay-v2', destDir: tmpDir('ov2-rd-con-dest') })
43 await pub.ready(); await con.ready()
44 t.teardown(async () => { try { await pub.close() } catch {}; try { await con.close() } catch {} })
45
46 const [sa, sb] = makeDuplex()
47 pub.attachProtocol(Protomux.from(sa))
48 con.attachProtocol(Protomux.from(sb))
49 await settle()
50 t.is(con.peerCount, 1, 'consumer paired with the cold publisher')
51
52 const got = await con.fetchFile(oid, { timeout: 6000 })
53 t.ok(got, 'consumer fetched from the cold publisher (served via content-addressed spool)')
54 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), content), 0, 'bytes match')
55})
test/integration/serve-bypass.test.js +108 -0
……@@ -0,0 +1,108 @@
1import test from 'brittle'
2import Protomux from 'protomux'
3import crypto from 'hypercore-crypto'
4import { Duplex } from 'streamx'
5import { tmpStore, tmpDir, fs, path } from '../helpers.js'
6import { HyperOverlay } from '../../lib/overlay.js'
7import { hashChunk } from '../../lib/chunker.js'
8
9// S1/S2: the membership serve gate lives at _onContentRequest, but the protocol
10// has OTHER serve/receive entry points. In gated mode (a serveAuthorizer is
11// configured) those must be refused, or a connected peer could (S1) pull bytes
12// via path-based fileRequest / direct chunkNeed without passing the gate, or (S2)
13// overwrite the owner's source file via an unsolicited chunkHashes push.
14function makeDuplex () {
15 let aWrite, bWrite
16 const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
17 const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
18 aWrite = (d) => a.push(d)
19 bWrite = (d) => b.push(d)
20 return [a, b]
21}
22const settle = (ms = 800) => new Promise((r) => setTimeout(r, ms))
23const MEMBER = 'a'.repeat(64)
24
25test('S1: serve gate cannot be bypassed via fileRequest or direct chunkNeed', async (t) => {
26 const pub = new HyperOverlay(tmpStore('byp-pub'), {
27 namespace: 'test-overlay', destDir: tmpDir('byp-pub-d'),
28 serveAuthorizer: async (peer, from) => from === MEMBER, // only MEMBER may fetch
29 })
30 await pub.ready()
31 t.teardown(async () => { try { await pub.close() } catch {} })
32
33 const content = crypto.randomBytes(128 * 1024)
34 const oid = crypto.data(content).toString('hex')
35 const src = path.join(tmpDir('byp-src'), 'doc.bin')
36 fs.writeFileSync(src, content)
37 await pub.registerFile('/mir/' + oid, src, { contentHash: oid, size: content.length })
38
39 // Spy the owner's serve primitive: readChunk is only called when it actually
40 // streams bytes out.
41 let served = 0
42 const realRead = pub._transfer.readChunk.bind(pub._transfer)
43 pub._transfer.readChunk = (...a) => { served++; return realRead(...a) }
44
45 // Attacker: connected, but never authorized (no MEMBER identity).
46 const atk = new HyperOverlay(tmpStore('byp-atk'), { namespace: 'test-overlay', destDir: tmpDir('byp-atk-d') })
47 await atk.ready()
48 t.teardown(async () => { try { await atk.close() } catch {} })
49 const [pa, pb] = makeDuplex()
50 pub.attachProtocol(Protomux.from(pa))
51 const atkPeer = atk.attachProtocol(Protomux.from(pb))
52 await settle()
53
54 // Bypass attempt 1: path-based fileRequest for the registered serve path.
55 atkPeer.msgs.fileRequest.send({ path: '/mir/' + oid, contentHash: oid, chunksHave: null })
56 // Bypass attempt 2: skip the request entirely, demand chunks directly.
57 atkPeer.msgs.chunkNeed.send({ path: '/mir/' + oid, indices: [0, 1, 2] })
58 await settle(1500)
59 t.is(served, 0, 'no bytes served to an unauthorized peer via fileRequest/chunkNeed')
60
61 // Positive control: a real MEMBER fetch DOES serve (proves the spy + serve path).
62 const member = new HyperOverlay(tmpStore('byp-mem'), {
63 namespace: 'test-overlay', destDir: tmpDir('byp-mem-d'), localProfileKey: MEMBER,
64 })
65 await member.ready()
66 t.teardown(async () => { try { await member.close() } catch {} })
67 const [ma, mb] = makeDuplex()
68 pub.attachProtocol(Protomux.from(ma))
69 member.attachProtocol(Protomux.from(mb))
70 await settle()
71 const got = await member.fetchFile(oid, { timeout: 6000, reSeed: false })
72 t.ok(got, 'authorized member fetch succeeds')
73 t.ok(served > 0, 'serve path was exercised for the authorized fetch (spy works)')
74})
75
76test('S2: an unsolicited chunkHashes push cannot overwrite the owner source file', async (t) => {
77 // Allow-all serve — proving the overwrite is blocked even when SERVING is open.
78 const pub = new HyperOverlay(tmpStore('ovw-pub'), {
79 namespace: 'test-overlay', destDir: tmpDir('ovw-pub-d'), serveAuthorizer: async () => true,
80 })
81 await pub.ready()
82 t.teardown(async () => { try { await pub.close() } catch {} })
83
84 const content = Buffer.from('the owner\'s real, precious source bytes')
85 const oid = crypto.data(content).toString('hex')
86 const src = path.join(tmpDir('ovw-src'), 'precious.txt')
87 fs.writeFileSync(src, content)
88 await pub.registerFile('/mir/' + oid, src, { contentHash: oid, size: content.length })
89
90 const atk = new HyperOverlay(tmpStore('ovw-atk'), { namespace: 'test-overlay', destDir: tmpDir('ovw-atk-d') })
91 await atk.ready()
92 t.teardown(async () => { try { await atk.close() } catch {} })
93 const [pa, pb] = makeDuplex()
94 pub.attachProtocol(Protomux.from(pa))
95 const atkPeer = atk.attachProtocol(Protomux.from(pb))
96 await settle()
97
98 // Forge a receive against the owner's OWN serve path: chunkHashes for /mir/<oid>
99 // with attacker-chosen chunks, then the matching chunkData. Without the guard
100 // the owner would startReceive on its own file and finalize-rename the attacker
101 // bytes over it.
102 const evil = Buffer.from('EVIL OVERWRITE PAYLOAD')
103 atkPeer.msgs.chunkHashes.send({ path: '/mir/' + oid, tier: 0, chunks: [{ hash: hashChunk(evil), length: evil.length }] })
104 atkPeer.msgs.chunkData.send({ path: '/mir/' + oid, index: 0, data: evil })
105 await settle(1500)
106
107 t.alike(fs.readFileSync(src), content, 'owner source file is byte-for-byte unchanged')
108})
test/integration/serve-chunkmap.test.js +73 -0
……@@ -0,0 +1,73 @@
1import test from 'brittle'
2import Protomux from 'protomux'
3import crypto from 'hypercore-crypto'
4import { Duplex } from 'streamx'
5import { tmpStore, tmpDir, fs, path } from '../helpers.js'
6import { HyperOverlay } from '../../lib/overlay.js'
7
8function makeDuplex () {
9 let aWrite, bWrite
10 const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
11 const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
12 aWrite = (d) => a.push(d)
13 bWrite = (d) => b.push(d)
14 return [a, b]
15}
16const settle = (ms = 800) => new Promise((r) => setTimeout(r, ms))
17
18// REGRESSION (FIX: multi-second stall before a large overlay download starts):
19// the owner re-chunked the whole file on every serve because the serve key
20// ('content:<hash>') differed from the publish key ('/mir/<hash>'), so the
21// publish-time chunk-map cache always missed. For a multi-GB file that is a
22// full-file read of latency BEFORE the first byte ships. The chunk map is
23// content-addressed, so publish and serve must share it — a publish-then-serve
24// must NOT re-chunk.
25test('owner reuses the publish-time chunk map at serve (no re-chunk)', async (t) => {
26 const pub = new HyperOverlay(tmpStore('cm-pub'), {
27 namespace: 'test-overlay', destDir: tmpDir('cm-pub-d'), serveAuthorizer: async () => true,
28 })
29 await pub.ready()
30 t.teardown(async () => { try { await pub.close() } catch {} })
31
32 const content = crypto.randomBytes(2 * 1024 * 1024) // 2MB → >1MB, so a chunk map persists
33 const oid = crypto.data(content).toString('hex')
34 const src = path.join(tmpDir('cm-src'), 'big.bin')
35 fs.writeFileSync(src, content)
36
37 // Publish — chunks the file exactly once.
38 await pub.registerFile('/mir/' + oid, src, { contentHash: oid, size: content.length })
39 t.ok(await pub._index.getChunkMapByHash(oid), 'publish populated the content-addressed chunk map')
40
41 // Spy AFTER publish: from here, any prepareFile call is a serve-time re-chunk.
42 let reChunks = 0
43 const realPrepare = pub._transfer.prepareFile.bind(pub._transfer)
44 pub._transfer.prepareFile = (...a) => { reChunks++; return realPrepare(...a) }
45
46 const con = new HyperOverlay(tmpStore('cm-con'), { namespace: 'test-overlay', destDir: tmpDir('cm-con-d') })
47 await con.ready()
48 t.teardown(async () => { try { await con.close() } catch {} })
49 const [pa, pb] = makeDuplex()
50 pub.attachProtocol(Protomux.from(pa))
51 con.attachProtocol(Protomux.from(pb))
52 await settle()
53
54 const got = await con.fetchFile(oid, { timeout: 8000, reSeed: false })
55 t.ok(got, 'consumer fetched the file')
56 t.is(Buffer.compare(fs.readFileSync(got.destPath), content), 0, 'bytes match')
57 t.is(reChunks, 0, 'owner did NOT re-chunk at serve — the publish-time map was reused')
58})
59
60test('REGRESSION (FIX-3): registerFile returns null (no throw) when the source is gone', async (t) => {
61 const ov = new HyperOverlay(tmpStore('rf-gone'), {
62 namespace: 'test-overlay', destDir: tmpDir('rf-gone-d'), serveAuthorizer: async () => true,
63 })
64 await ov.ready()
65 t.teardown(async () => { try { await ov.close() } catch {} })
66
67 // Source moved out between prepare and register (narrow TOCTOU). Pre-fix this throws
68 // ENOENT out of makeServable; post-fix it bails with null.
69 const res = await ov.registerFile('/mir/deadbeef', path.join(tmpDir('rf-gone-src'), 'nope.bin'), {
70 contentHash: 'deadbeef', size: 123,
71 })
72 t.is(res, null, 'missing source → null, not a thrown ENOENT')
73})
test/integration/serve-gate.test.js +72 -0
……@@ -0,0 +1,72 @@
1// Exercise the serve-authorization hook end-to-end through the real
2// protocol. A publisher registers a file with a serveAuthorizer
3// that only admits a specific requester identity; an approved consumer fetches
4// the bytes, a non-member gets a "miss" (silent drop → fetch returns null).
5// This proves the injected gate denies WITHOUT a membership oracle.
6import test from 'brittle'
7import Protomux from 'protomux'
8import crypto from 'hypercore-crypto'
9import { Duplex } from 'streamx'
10import { tmpStore, tmpDir, fs, path } from '../helpers.js'
11import { HyperOverlay } from '../../lib/overlay.js'
12
13function makeDuplex () {
14 let aWrite, bWrite
15 const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
16 const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
17 aWrite = (d) => a.push(d)
18 bWrite = (d) => b.push(d)
19 return [a, b]
20}
21
22const settle = (ms = 400) => new Promise(r => setTimeout(r, ms))
23const MEMBER = 'a'.repeat(64)
24const STRANGER = 'b'.repeat(64)
25
26test('serve gate: approved member fetches, non-member gets a miss', async (t) => {
27 // Publisher admits ONLY the MEMBER identity (stand-in for the real MIR-01/28 gate).
28 const seen = []
29 const pub = new HyperOverlay(tmpStore('ov2-gate-pub'), {
30 namespace: 'test-overlay',
31 destDir: tmpDir('ov2-gate-pub-dest'),
32 serveAuthorizer: async (peer, from, contentHash) => { seen.push(from); return from === MEMBER },
33 })
34 const member = new HyperOverlay(tmpStore('ov2-gate-member'), {
35 namespace: 'test-overlay', destDir: tmpDir('ov2-gate-member-dest'), localProfileKey: MEMBER,
36 })
37 const stranger = new HyperOverlay(tmpStore('ov2-gate-stranger'), {
38 namespace: 'test-overlay', destDir: tmpDir('ov2-gate-stranger-dest'), localProfileKey: STRANGER,
39 })
40 await pub.ready(); await member.ready(); await stranger.ready()
41 t.teardown(async () => {
42 for (const o of [pub, member, stranger]) { try { await o.close() } catch {} }
43 })
44
45 // Publish a file servable by content hash.
46 const content = crypto.randomBytes(200 * 1024)
47 const oid = crypto.data(content).toString('hex')
48 const srcDir = tmpDir('ov2-gate-src')
49 const srcPath = path.join(srcDir, 'doc.bin')
50 fs.writeFileSync(srcPath, content)
51 await pub.registerFile('/mir/' + oid, srcPath, { contentHash: oid, size: content.length })
52
53 // Member connection → fetch succeeds, bytes match.
54 const [ma, mb] = makeDuplex()
55 pub.attachProtocol(Protomux.from(ma))
56 member.attachProtocol(Protomux.from(mb))
57 await settle()
58 const got = await member.fetchFile(oid, { timeout: 6000, reSeed: false })
59 t.ok(got, 'approved member fetched the file')
60 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), content), 0, 'bytes match')
61
62 // Stranger connection → denied → silent drop → fetch times out → null.
63 const [sa, sb] = makeDuplex()
64 pub.attachProtocol(Protomux.from(sa))
65 stranger.attachProtocol(Protomux.from(sb))
66 await settle()
67 const denied = await stranger.fetchFile(oid, { timeout: 1500, peerWaitMs: 1000, reSeed: false })
68 t.is(denied, null, 'non-member got a miss (no bytes, no oracle)')
69
70 t.ok(seen.includes(MEMBER), 'gate saw the member identity')
71 t.ok(seen.includes(STRANGER), 'gate saw (and rejected) the stranger identity')
72})
test/integration/sync-engine.test.js +346 -0
……@@ -0,0 +1,346 @@
1import test from 'brittle'
2import { tmpStore } from '../helpers.js'
3import { FileIndex } from '../../lib/file-index.js'
4import { SyncEngine } from '../../lib/sync-engine.js'
5
6async function setup () {
7 const store = tmpStore('sync')
8 const index = new FileIndex(store)
9 await index.ready()
10 const engine = new SyncEngine(index, store)
11 await engine.ready()
12 return { store, index, engine }
13}
14
15// ── Change logging ────────────────────────────────────────────
16
17test('logPut appends to feed', async (t) => {
18 const { engine } = await setup()
19
20 await engine.logPut('/docs/a.txt', { contentHash: 'h1', size: 100, mtime: 1000 })
21 await engine.logPut('/docs/b.txt', { contentHash: 'h2', size: 200, mtime: 2000 })
22
23 t.is(engine.feed.length, 2)
24
25 const entry = await engine.feed.get(0)
26 t.is(entry.op, 0) // OP_PUT
27 t.is(entry.path, '/docs/a.txt')
28 t.is(entry.hash, 'h1')
29 t.is(entry.size, 100)
30
31 await engine.close()
32})
33
34test('logDel appends deletion to feed', async (t) => {
35 const { engine } = await setup()
36
37 await engine.logPut('/file.txt', { contentHash: 'h1', size: 100, mtime: 1000 })
38 await engine.logDel('/file.txt')
39
40 t.is(engine.feed.length, 2)
41
42 const del = await engine.feed.get(1)
43 t.is(del.op, 1) // OP_DEL
44 t.is(del.path, '/file.txt')
45 t.is(del.hash, null)
46
47 await engine.close()
48})
49
50test('feedKey returns hex string', async (t) => {
51 const { engine } = await setup()
52
53 t.is(typeof engine.feedKey, 'string')
54 t.is(engine.feedKey.length, 64)
55
56 await engine.close()
57})
58
59// ── Feed diffing ──────────────────────────────────────────────
60
61test('getChanges returns entries since seq', async (t) => {
62 const { engine } = await setup()
63
64 await engine.logPut('/a', { contentHash: 'h1', size: 1, mtime: 1 })
65 await engine.logPut('/b', { contentHash: 'h2', size: 2, mtime: 2 })
66 await engine.logPut('/c', { contentHash: 'h3', size: 3, mtime: 3 })
67
68 const all = await engine.getChanges(engine.feed, -1)
69 t.is(all.length, 3)
70
71 const since1 = await engine.getChanges(engine.feed, 0)
72 t.is(since1.length, 2)
73 t.is(since1[0].path, '/b')
74 t.is(since1[1].path, '/c')
75
76 const since2 = await engine.getChanges(engine.feed, 1)
77 t.is(since2.length, 1)
78 t.is(since2[0].path, '/c')
79
80 await engine.close()
81})
82
83test('buildChangeMap keeps latest per path', async (t) => {
84 const { engine } = await setup()
85
86 await engine.logPut('/a', { contentHash: 'old', size: 1, mtime: 1 })
87 await engine.logPut('/b', { contentHash: 'h2', size: 2, mtime: 2 })
88 await engine.logPut('/a', { contentHash: 'new', size: 3, mtime: 3 })
89
90 const changes = await engine.getChanges(engine.feed, -1)
91 const map = engine.buildChangeMap(changes)
92
93 t.is(map.size, 2)
94 t.is(map.get('/a').hash, 'new', 'latest version wins')
95 t.is(map.get('/b').hash, 'h2')
96
97 await engine.close()
98})
99
100// ── Peer feeds ────────────────────────────────────────────────
101
102test('addPeerFeed + getPeerFeed', async (t) => {
103 const { engine } = await setup()
104
105 const peerStore = tmpStore('peer')
106 const peerCore = peerStore.get({ name: 'sync-feed', valueEncoding: 'json' })
107 await peerCore.ready()
108
109 const peerKey = peerCore.key.toString('hex')
110 const feed = engine.addPeerFeed(peerKey)
111 t.ok(feed)
112 t.is(engine.getPeerFeed(peerKey), feed, 'same instance returned')
113 t.is(engine.getPeerFeed('nonexistent'), null)
114
115 await engine.close()
116})
117
118// ── Sync diff ─────────────────────────────────────────────────
119
120test('diff — only they have a file → pull', async (t) => {
121 // Create peer with their own store and feed
122 const peerStore = tmpStore('peer')
123 const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
124 await peerFeed.ready()
125 await peerFeed.append({ op: 0, path: '/their-file.txt', hash: 'hh1', size: 100, mtime: 1000, timestamp: Date.now() })
126
127 // Set up local engine with empty feed
128 const { engine } = await setup()
129 const peerKey = peerFeed.key.toString('hex')
130 engine.addPeerFeed(peerFeed.key)
131
132 // Need to replicate the peer feed into our corestore
133 // Simpler: directly use the peer feed object
134 engine._peerFeeds.set(peerKey, peerFeed)
135
136 const actions = await engine.diff(peerKey)
137 const pullActions = actions.filter(a => a.action === 'pull')
138 t.is(pullActions.length, 1)
139 t.is(pullActions[0].path, '/their-file.txt')
140
141 await engine.close()
142})
143
144test('diff — only I have a file → push', async (t) => {
145 const peerStore = tmpStore('peer')
146 const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
147 await peerFeed.ready()
148
149 const { engine } = await setup()
150 await engine.logPut('/my-file.txt', { contentHash: 'mh1', size: 50, mtime: 500 })
151
152 const peerKey = peerFeed.key.toString('hex')
153 engine._peerFeeds.set(peerKey, peerFeed)
154
155 const actions = await engine.diff(peerKey)
156 const pushActions = actions.filter(a => a.action === 'push')
157 t.is(pushActions.length, 1)
158 t.is(pushActions[0].path, '/my-file.txt')
159
160 await engine.close()
161})
162
163test('diff — same hash both sides → skip', async (t) => {
164 const peerStore = tmpStore('peer')
165 const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
166 await peerFeed.ready()
167 await peerFeed.append({ op: 0, path: '/shared.txt', hash: 'same', size: 100, mtime: 1000, timestamp: Date.now() })
168
169 const { engine } = await setup()
170 await engine.logPut('/shared.txt', { contentHash: 'same', size: 100, mtime: 1000 })
171
172 const peerKey = peerFeed.key.toString('hex')
173 engine._peerFeeds.set(peerKey, peerFeed)
174
175 const actions = await engine.diff(peerKey)
176 const skipActions = actions.filter(a => a.action === 'skip')
177 t.is(skipActions.length, 1)
178 t.is(skipActions[0].path, '/shared.txt')
179
180 await engine.close()
181})
182
183test('diff — both changed with ancestor → conflict', async (t) => {
184 const peerStore = tmpStore('peer')
185 const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
186 await peerFeed.ready()
187 await peerFeed.append({ op: 0, path: '/doc.txt', hash: 'their-version', size: 200, mtime: 2000, timestamp: Date.now() })
188
189 const { engine, index } = await setup()
190 await engine.logPut('/doc.txt', { contentHash: 'my-version', size: 150, mtime: 1500 })
191
192 const peerKey = peerFeed.key.toString('hex')
193 engine._peerFeeds.set(peerKey, peerFeed)
194
195 // Set ancestor — both sides were at 'original' before
196 await index.putSyncState(peerKey, '/doc.txt', { lastSeq: 0, lastHash: 'original' })
197
198 const actions = await engine.diff(peerKey)
199 const conflicts = actions.filter(a => a.action === 'conflict')
200 t.is(conflicts.length, 1)
201 t.is(conflicts[0].path, '/doc.txt')
202
203 await engine.close()
204})
205
206test('diff — they changed, I have ancestor → pull', async (t) => {
207 const peerStore = tmpStore('peer')
208 const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
209 await peerFeed.ready()
210 await peerFeed.append({ op: 0, path: '/doc.txt', hash: 'updated', size: 200, mtime: 2000, timestamp: Date.now() })
211
212 const { engine, index } = await setup()
213 await engine.logPut('/doc.txt', { contentHash: 'original', size: 100, mtime: 1000 })
214
215 const peerKey = peerFeed.key.toString('hex')
216 engine._peerFeeds.set(peerKey, peerFeed)
217
218 // My hash matches ancestor — only they changed
219 await index.putSyncState(peerKey, '/doc.txt', { lastSeq: 0, lastHash: 'original' })
220
221 const actions = await engine.diff(peerKey)
222 const pulls = actions.filter(a => a.action === 'pull')
223 t.is(pulls.length, 1)
224
225 await engine.close()
226})
227
228test('diff — I changed, they have ancestor → push', async (t) => {
229 const peerStore = tmpStore('peer')
230 const peerFeed = peerStore.get({ name: 'peer-feed', valueEncoding: 'json' })
231 await peerFeed.ready()
232 await peerFeed.append({ op: 0, path: '/doc.txt', hash: 'original', size: 100, mtime: 1000, timestamp: Date.now() })
233
234 const { engine, index } = await setup()
235 await engine.logPut('/doc.txt', { contentHash: 'updated', size: 200, mtime: 2000 })
236
237 const peerKey = peerFeed.key.toString('hex')
238 engine._peerFeeds.set(peerKey, peerFeed)
239
240 // Their hash matches ancestor — only I changed
241 await index.putSyncState(peerKey, '/doc.txt', { lastSeq: 0, lastHash: 'original' })
242
243 const actions = await engine.diff(peerKey)
244 const pushes = actions.filter(a => a.action === 'push')
245 t.is(pushes.length, 1)
246
247 await engine.close()
248})
249
250// ── Conflict resolution ───────────────────────────────────────
251
252test('resolveConflict — mine-wins', async (t) => {
253 const { engine } = await setup()
254
255 const conflict = { path: '/doc.txt', local: { seq: 5, hash: 'mine' }, remote: { seq: 3, hash: 'theirs' } }
256 const result = engine.resolveConflict(conflict, 'mine-wins')
257
258 t.is(result.action, 'push')
259 t.is(result.winner, 'local')
260
261 await engine.close()
262})
263
264test('resolveConflict — theirs-wins', async (t) => {
265 const { engine } = await setup()
266
267 const conflict = { path: '/doc.txt', local: { seq: 5, hash: 'mine' }, remote: { seq: 3, hash: 'theirs' } }
268 const result = engine.resolveConflict(conflict, 'theirs-wins')
269
270 t.is(result.action, 'pull')
271 t.is(result.winner, 'remote')
272
273 await engine.close()
274})
275
276test('resolveConflict — newest-wins uses seq number', async (t) => {
277 const { engine } = await setup()
278
279 const conflict1 = { path: '/a', local: { seq: 5 }, remote: { seq: 3 } }
280 t.is(engine.resolveConflict(conflict1, 'newest-wins').winner, 'local')
281
282 const conflict2 = { path: '/b', local: { seq: 2 }, remote: { seq: 7 } }
283 t.is(engine.resolveConflict(conflict2, 'newest-wins').winner, 'remote')
284
285 await engine.close()
286})
287
288test('resolveConflict — keep-both', async (t) => {
289 const { engine } = await setup()
290
291 const conflict = { path: '/doc.txt', local: { seq: 5 }, remote: { seq: 3 } }
292 const result = engine.resolveConflict(conflict, 'keep-both')
293
294 t.is(result.action, 'keep-both')
295
296 await engine.close()
297})
298
299test('resolveConflict — prompt adds to conflict queue', async (t) => {
300 const { engine } = await setup()
301
302 const conflict = { path: '/doc.txt', local: { seq: 5 }, remote: { seq: 3 } }
303 engine.resolveConflict(conflict, 'prompt')
304
305 t.is(engine.conflicts.length, 1)
306 t.is(engine.conflicts[0].path, '/doc.txt')
307
308 await engine.close()
309})
310
311test('resolveQueued removes from queue', async (t) => {
312 const { engine } = await setup()
313
314 const conflict = { path: '/doc.txt', local: { seq: 5 }, remote: { seq: 3 } }
315 engine.resolveConflict(conflict, 'prompt')
316 t.is(engine.conflicts.length, 1)
317
318 const result = engine.resolveQueued('/doc.txt', 'overwrite')
319 t.is(result.action, 'pull')
320 t.is(engine.conflicts.length, 0)
321
322 await engine.close()
323})
324
325test('resolveQueued returns null for unknown path', async (t) => {
326 const { engine } = await setup()
327
328 t.is(engine.resolveQueued('/nope', 'overwrite'), null)
329
330 await engine.close()
331})
332
333// ── markSynced ────────────────────────────────────────────────
334
335test('markSynced updates file index sync state', async (t) => {
336 const { engine, index } = await setup()
337
338 await engine.markSynced('peer1', '/doc.txt', 'abc123', 42)
339
340 const state = await index.getSyncState('peer1', '/doc.txt')
341 t.ok(state)
342 t.is(state.lastHash, 'abc123')
343 t.is(state.lastSeq, 42)
344
345 await engine.close()
346})
test/integration/transfer.test.js +1200 -0
……@@ -0,0 +1,1200 @@
1// TransferManager suite — prepareFile (block reads, mtime/shrink guards,
2// cancellation), receive/resume (journal O(1) resume, async re-verify,
3// background hash pump), pause/cancel, finalize, journal lifecycle.
4import test from 'brittle'
5import { tmpStore, tmpDir, fs, path } from '../helpers.js'
6import { FileIndex } from '../../lib/file-index.js'
7import { TransferManager } from '../../lib/transfer.js'
8import { hashChunk, selectTier, chunk as chunkBuffer } from '../../lib/chunker.js'
9import crypto from 'hypercore-crypto'
10
11async function setup (transferOpts) {
12 const store = tmpStore('transfer')
13 const index = new FileIndex(store)
14 await index.ready()
15 const journalDir = tmpDir('journals')
16 const transfer = new TransferManager(index, { journalDir, ...transferOpts })
17 return { store, index, transfer, journalDir }
18}
19
20function wipeJournals (journalDir) {
21 for (const f of fs.readdirSync(journalDir)) fs.unlinkSync(path.join(journalDir, f))
22}
23
24function writeTestFile (dir, name, data) {
25 fs.mkdirSync(dir, { recursive: true })
26 const filePath = path.join(dir, name)
27 fs.writeFileSync(filePath, data)
28 return filePath
29}
30
31// ── Sender: prepareFile ───────────────────────────────────────
32
33test('prepareFile — chunks a small file', async (t) => {
34 const { index, transfer } = await setup()
35 const dir = tmpDir('sender')
36 const data = Buffer.from('Hello, this is a test file for transfer.')
37 const filePath = writeTestFile(dir, 'hello.txt', data)
38
39 const result = await transfer.prepareFile(filePath, '/hello.txt')
40
41 t.ok(result)
42 t.is(result.size, data.length)
43 t.is(result.tier, 0, 'small file = tier 0')
44 t.is(result.chunks.length, 1, 'small file = 1 chunk')
45 t.ok(result.chunks[0].hash)
46 t.is(result.chunks[0].offset, 0)
47 t.is(result.chunks[0].length, data.length)
48
49 // File index should be updated
50 const meta = await index.getFile('/hello.txt')
51 t.ok(meta)
52 t.is(meta.size, data.length)
53 t.ok(meta.contentHash)
54
55 // Small file should NOT have a persisted chunk map
56 t.is(await index.hasChunkMap('/hello.txt'), false)
57
58 await index.close()
59})
60
61test('prepareFile — large file persists chunk map', async (t) => {
62 const { index, transfer } = await setup()
63 const dir = tmpDir('sender')
64 const data = crypto.randomBytes(2 * 1024 * 1024) // 2MB
65 const filePath = writeTestFile(dir, 'large.bin', data)
66
67 const result = await transfer.prepareFile(filePath, '/large.bin')
68
69 t.ok(result)
70 t.is(result.size, data.length)
71 t.is(result.tier, 1, '2MB = tier 1')
72 t.ok(result.chunks.length > 1)
73
74 // Large file SHOULD have a persisted chunk map
75 t.is(await index.hasChunkMap('/large.bin'), true)
76
77 const map = await index.getChunkMap('/large.bin')
78 t.is(map.length, result.chunks.length)
79
80 await index.close()
81})
82
83test('prepareFile — uses cached chunk map on second call', async (t) => {
84 const { index, transfer } = await setup()
85 const dir = tmpDir('sender')
86 const data = crypto.randomBytes(2 * 1024 * 1024)
87 const filePath = writeTestFile(dir, 'cached.bin', data)
88
89 const result1 = await transfer.prepareFile(filePath, '/cached.bin')
90 const result2 = await transfer.prepareFile(filePath, '/cached.bin')
91
92 t.is(result1.chunks.length, result2.chunks.length)
93 t.is(result1.chunks[0].hash, result2.chunks[0].hash)
94
95 await index.close()
96})
97
98test('prepareFile — returns null for missing file', async (t) => {
99 const { index, transfer } = await setup()
100
101 const result = await transfer.prepareFile('/nonexistent/file.txt', '/file.txt')
102 t.is(result, null)
103
104 await index.close()
105})
106
107test('REGRESSION (FIX-1: vanish mid-read): prepareFile re-queues (null), does not throw', async (t) => {
108 const { index, transfer } = await setup()
109 t.teardown(() => index.close())
110 const dir = tmpDir('sender')
111 const data = crypto.randomBytes(2 * 1024 * 1024) // >1MB → chunk-map branch (the 43GB repro path)
112 const filePath = writeTestFile(dir, 'vanish.bin', data)
113
114 // prepareFile stats filePath exactly twice: the pre-read stat (line 78) succeeds, then the
115 // post-read mtime guard re-stats the now-moved-out path and trips ENOENT (the open fd kept
116 // the streaming read alive to the end; the read uses fs.open/fs.read, not statSync). Count
117 // only stats of the target path so the stub fails ONLY that second stat — immune to any
118 // unrelated statSync calls regardless of how the runner schedules tests.
119 const realStat = fs.statSync
120 let targetStats = 0
121 fs.statSync = (p, ...rest) => {
122 if (p === filePath && ++targetStats >= 2) {
123 const e = new Error(`ENOENT: no such file or directory, stat "${p}"`)
124 e.code = 'ENOENT'
125 throw e
126 }
127 return realStat(p, ...rest)
128 }
129 t.teardown(() => { fs.statSync = realStat })
130
131 const result = await transfer.prepareFile(filePath, '/mir-prep' + filePath, { byHashOnly: true })
132 t.is(result, null, 'vanished-mid-read source re-queues instead of throwing')
133 t.is(targetStats, 2, 'the post-read guard stat (the 2nd stat of the source) was reached')
134})
135
136test('prepareFile — returns the content hash and reports streaming progress', async (t) => {
137 const { index, transfer } = await setup()
138 const data = crypto.randomBytes(256 * 1024)
139 const filePath = writeTestFile(tmpDir('sender'), 'h.bin', data)
140 let seen = 0
141 const prepared = await transfer.prepareFile(filePath, '/h.bin', { onProgress: (n) => { seen += n } })
142 t.is(prepared.contentHash, crypto.data(data).toString('hex'), 'content hash == the wire (size-bound) hash')
143 t.is(seen, data.length, 'onProgress summed to the whole file')
144 // The cached second call still surfaces the content hash (from the file index).
145 const again = await transfer.prepareFile(filePath, '/h.bin')
146 t.is(again.contentHash, prepared.contentHash, 'cached path returns the content hash too')
147 await index.close()
148})
149
150test('prepareFile — no data field in chunks', async (t) => {
151 const { index, transfer } = await setup()
152 const dir = tmpDir('sender')
153 const data = crypto.randomBytes(100 * 1024)
154 const filePath = writeTestFile(dir, 'nodata.bin', data)
155
156 const result = await transfer.prepareFile(filePath, '/nodata.bin')
157
158 for (const c of result.chunks) {
159 t.absent(c.data, 'no data in chunk metadata')
160 }
161
162 await index.close()
163})
164
165// ── Sender: readChunk ─────────────────────────────────────────
166
167test('readChunk — reads bytes at offset', async (t) => {
168 const { index, transfer } = await setup()
169 const dir = tmpDir('sender')
170 const data = Buffer.from('AAAABBBBCCCCDDDD')
171 const filePath = writeTestFile(dir, 'chunks.bin', data)
172
173 const chunk = transfer.readChunk(filePath, 4, 4)
174 t.ok(chunk)
175 t.alike(chunk, Buffer.from('BBBB'))
176
177 const chunk2 = transfer.readChunk(filePath, 8, 8)
178 t.alike(chunk2, Buffer.from('CCCCDDDD'))
179
180 await index.close()
181})
182
183test('readChunk — returns null for missing file', async (t) => {
184 const { transfer } = await setup()
185 t.is(transfer.readChunk('/nonexistent', 0, 10), null)
186})
187
188// ── Sender: computeNeeded ─────────────────────────────────────
189
190test('computeNeeded — filters out chunks peer already has', async (t) => {
191 const { transfer } = await setup()
192
193 const offered = [
194 { hash: 'h1', offset: 0, length: 100 },
195 { hash: 'h2', offset: 100, length: 100 },
196 { hash: 'h3', offset: 200, length: 100 },
197 { hash: 'h4', offset: 300, length: 100 }
198 ]
199
200 const peerHas = new Set(['h1', 'h3'])
201 const needed = transfer.computeNeeded(offered, peerHas)
202
203 t.alike(needed, [1, 3], 'only indices 1 and 3 needed')
204})
205
206test('computeNeeded — peer has nothing → all needed', async (t) => {
207 const { transfer } = await setup()
208
209 const offered = [
210 { hash: 'h1', offset: 0, length: 100 },
211 { hash: 'h2', offset: 100, length: 100 }
212 ]
213
214 const needed = transfer.computeNeeded(offered, new Set())
215 t.alike(needed, [0, 1])
216})
217
218test('computeNeeded — peer has everything → nothing needed', async (t) => {
219 const { transfer } = await setup()
220
221 const offered = [
222 { hash: 'h1', offset: 0, length: 100 },
223 { hash: 'h2', offset: 100, length: 100 }
224 ]
225
226 const needed = transfer.computeNeeded(offered, new Set(['h1', 'h2']))
227 t.alike(needed, [])
228})
229
230// ── Receiver: full transfer flow ──────────────────────────────
231
232test('receive flow — small file round-trip', async (t) => {
233 const { index, transfer } = await setup()
234
235 // Sender side: prepare the file
236 const senderDir = tmpDir('sender')
237 const original = Buffer.from('This is the file content to transfer over P2P.')
238 const senderPath = writeTestFile(senderDir, 'doc.txt', original)
239 const prepared = await transfer.prepareFile(senderPath, '/doc.txt')
240
241 // Receiver side: start receiving
242 const receiverDir = tmpDir('receiver')
243 const targetPath = path.join(receiverDir, 'doc.txt')
244 const state = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
245
246 t.ok(state.partialPath.endsWith('.overlay-partial'))
247 t.is(state.total, 1)
248
249 // Simulate chunk transfer: sender reads, receiver writes
250 for (let i = 0; i < prepared.chunks.length; i++) {
251 const c = prepared.chunks[i]
252 const data = transfer.readChunk(senderPath, c.offset, c.length)
253 const result = transfer.writeChunk(targetPath, i, data)
254 t.ok(result.ok, `chunk ${i} written`)
255 }
256
257 // Check progress
258 t.is(transfer.isComplete(targetPath), true)
259 const progress = transfer.getProgress(targetPath)
260 t.is(progress.percentage, 100)
261
262 // Finalize
263 const fin = await transfer.finalize(targetPath)
264 t.ok(fin.ok, 'finalized')
265
266 // Verify the file on disk
267 const received = fs.readFileSync(targetPath)
268 t.alike(received, original, 'file content matches')
269
270 // Partial file should be gone
271 t.is(fs.existsSync(state.partialPath), false, 'partial file removed')
272
273 await index.close()
274})
275
276test('receive flow — multi-chunk file', async (t) => {
277 const { index, transfer } = await setup()
278
279 const senderDir = tmpDir('sender')
280 const original = crypto.randomBytes(128 * 1024) // 128KB, multiple chunks
281 const senderPath = writeTestFile(senderDir, 'multi.bin', original)
282 const prepared = await transfer.prepareFile(senderPath, '/multi.bin')
283
284 t.ok(prepared.chunks.length > 1, `${prepared.chunks.length} chunks`)
285
286 const receiverDir = tmpDir('receiver')
287 const targetPath = path.join(receiverDir, 'multi.bin')
288 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
289
290 // Transfer all chunks
291 for (let i = 0; i < prepared.chunks.length; i++) {
292 const c = prepared.chunks[i]
293 const data = transfer.readChunk(senderPath, c.offset, c.length)
294 const result = transfer.writeChunk(targetPath, i, data)
295 t.ok(result.ok)
296 }
297
298 t.ok(transfer.isComplete(targetPath))
299 await transfer.finalize(targetPath)
300
301 const received = fs.readFileSync(targetPath)
302 t.alike(received, original, 'content matches')
303
304 await index.close()
305})
306
307test('receive flow — out-of-order chunks', async (t) => {
308 const { index, transfer } = await setup()
309
310 const senderDir = tmpDir('sender')
311 const original = crypto.randomBytes(128 * 1024)
312 const senderPath = writeTestFile(senderDir, 'ooo.bin', original)
313 const prepared = await transfer.prepareFile(senderPath, '/ooo.bin')
314
315 const receiverDir = tmpDir('receiver')
316 const targetPath = path.join(receiverDir, 'ooo.bin')
317 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
318
319 // Send chunks in reverse order
320 for (let i = prepared.chunks.length - 1; i >= 0; i--) {
321 const c = prepared.chunks[i]
322 const data = transfer.readChunk(senderPath, c.offset, c.length)
323 const result = transfer.writeChunk(targetPath, i, data)
324 t.ok(result.ok)
325 }
326
327 t.ok(transfer.isComplete(targetPath))
328 await transfer.finalize(targetPath)
329
330 const received = fs.readFileSync(targetPath)
331 t.alike(received, original, 'content matches even with out-of-order chunks')
332
333 await index.close()
334})
335
336// REGRESSION (FIX: end-of-download delay): when startReceive is given the
337// expected content hash, the WHOLE-file digest is computed incrementally as
338// chunks land — finalize verifies it with no trailing re-read of the file.
339test('incremental verify — correct content hash finalizes (in-order)', async (t) => {
340 const { index, transfer } = await setup()
341 const original = crypto.randomBytes(256 * 1024)
342 const senderPath = writeTestFile(tmpDir('sender'), 'iv.bin', original)
343 const oid = crypto.data(original).toString('hex')
344 const prepared = await transfer.prepareFile(senderPath, '/iv.bin')
345
346 const targetPath = path.join(tmpDir('receiver'), 'iv.bin')
347 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
348 for (let i = 0; i < prepared.chunks.length; i++) {
349 const c = prepared.chunks[i]
350 t.ok(transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length)).ok)
351 }
352 const fin = await transfer.finalize(targetPath)
353 t.ok(fin.ok, 'finalize ok with the correct content hash')
354 t.alike(fs.readFileSync(targetPath), original, 'bytes match')
355
356 await index.close()
357})
358
359test('incremental verify — correct hash finalizes even with out-of-order arrival', async (t) => {
360 const { index, transfer } = await setup()
361 const original = crypto.randomBytes(256 * 1024)
362 const senderPath = writeTestFile(tmpDir('sender'), 'ivo.bin', original)
363 const oid = crypto.data(original).toString('hex')
364 const prepared = await transfer.prepareFile(senderPath, '/ivo.bin')
365
366 const targetPath = path.join(tmpDir('receiver'), 'ivo.bin')
367 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
368 for (let i = prepared.chunks.length - 1; i >= 0; i--) {
369 const c = prepared.chunks[i]
370 t.ok(transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length)).ok)
371 }
372 const fin = await transfer.finalize(targetPath)
373 t.ok(fin.ok, 'finalize ok — the hash frontier handles reordering')
374 t.alike(fs.readFileSync(targetPath), original, 'bytes match')
375
376 await index.close()
377})
378
379test('incremental verify — wrong content hash rejects in finalize, no file lands', async (t) => {
380 const { index, transfer } = await setup()
381 const original = crypto.randomBytes(256 * 1024)
382 const senderPath = writeTestFile(tmpDir('sender'), 'ivm.bin', original)
383 const prepared = await transfer.prepareFile(senderPath, '/ivm.bin')
384
385 const targetPath = path.join(tmpDir('receiver'), 'ivm.bin')
386 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: 'f'.repeat(64) })
387 for (let i = 0; i < prepared.chunks.length; i++) {
388 const c = prepared.chunks[i]
389 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
390 }
391 const fin = await transfer.finalize(targetPath)
392 t.absent(fin.ok, 'finalize rejects on whole-file hash mismatch')
393 t.is(fin.code, 'EHASHMISMATCH', 'surfaces EHASHMISMATCH')
394 t.absent(fs.existsSync(targetPath), 'no file landed on mismatch')
395
396 await index.close()
397})
398
399// ── Resume + pause ────────────────────────────────────────────
400
401function partialFor (targetPath) {
402 return path.join(path.dirname(targetPath), path.basename(targetPath) + '.overlay-partial')
403}
404
405test('startReceive resumes a partial: verified chunks kept, gaps re-needed', async (t) => {
406 const { index, transfer } = await setup()
407 const senderPath = writeTestFile(tmpDir('sender'), 'resume.bin', crypto.randomBytes(256 * 1024))
408 const prepared = await transfer.prepareFile(senderPath, '/resume.bin')
409 t.ok(prepared.chunks.length > 4, `${prepared.chunks.length} chunks`)
410
411 const targetPath = path.join(tmpDir('receiver'), 'resume.bin')
412 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
413 const half = Math.floor(prepared.chunks.length / 2)
414 for (let i = 0; i < half; i++) {
415 const c = prepared.chunks[i]
416 t.ok(transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length)).ok)
417 }
418 await transfer.pause(targetPath) // keeps the partial on disk
419 t.ok(fs.existsSync(partialFor(targetPath)), 'partial kept after pause')
420
421 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
422 t.is(st.received.size, half, 'written chunks recognized from the partial on resume')
423 for (let i = 0; i < half; i++) t.ok(st.received.has(i), `chunk ${i} present`)
424 for (let i = half; i < prepared.chunks.length; i++) t.absent(st.received.has(i), `unwritten chunk ${i} not present`)
425
426 // Finish the resumed transfer from where it left off.
427 for (let i = half; i < prepared.chunks.length; i++) {
428 const c = prepared.chunks[i]
429 t.ok(transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length)).ok)
430 }
431 t.ok((await transfer.finalize(targetPath)).ok, 'resumed transfer finalizes')
432 t.alike(fs.readFileSync(targetPath), fs.readFileSync(senderPath), 'resumed file matches the source')
433
434 await index.close()
435})
436
437test('B: prepareFile aborts on an aborted signal (ECANCELLED), reader torn down', async (t) => {
438 const { index, transfer } = await setup()
439 const p = writeTestFile(tmpDir('sender'), 'abrt.bin', crypto.randomBytes(4 * 1024 * 1024))
440 await t.exception(transfer.prepareFile(p, '/abrt.bin', { signal: { aborted: true } }), /abort/i, 'a pre-aborted signal stops the hash')
441 await index.close()
442})
443
444test('a corrupted partial chunk is not trusted on resume (re-needed)', async (t) => {
445 const { index, transfer } = await setup()
446 const senderPath = writeTestFile(tmpDir('sender'), 'corrupt.bin', crypto.randomBytes(256 * 1024))
447 const prepared = await transfer.prepareFile(senderPath, '/corrupt.bin')
448
449 const targetPath = path.join(tmpDir('receiver'), 'corrupt.bin')
450 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
451 for (let i = 0; i < prepared.chunks.length; i++) {
452 const c = prepared.chunks[i]
453 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
454 }
455 await transfer.pause(targetPath)
456
457 const fd = fs.openSync(partialFor(targetPath), 'r+')
458 fs.writeSync(fd, Buffer.alloc(64, 0xff), 0, 64, prepared.chunks[2].offset)
459 fs.closeSync(fd)
460
461 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks })
462 t.absent(st.received.has(2), 'corrupted chunk fails verification and is re-needed')
463 t.ok(st.received.has(0) && st.received.has(1) && st.received.has(3), 'intact chunks still recognized')
464
465 transfer.cancel(targetPath)
466 await index.close()
467})
468
469test('pause keeps the partial (active transfer cleared); cancel needs an active transfer', async (t) => {
470 const { index, transfer } = await setup()
471 const targetPath = path.join(tmpDir('receiver'), 'pc.bin')
472 const state = await transfer.startReceive(targetPath, { size: 100, chunks: [{ hash: 'h1', offset: 0, length: 100 }] })
473 await transfer.pause(targetPath)
474 t.ok(fs.existsSync(state.partialPath), 'pause keeps the partial on disk')
475 t.is(transfer.getProgress(targetPath), null, 'pause removes the active transfer')
476 fs.unlinkSync(state.partialPath) // the loose layer unlinks a paused partial by path on discard
477 await index.close()
478})
479
480// ── Receiver: hash verification ───────────────────────────────
481
482test('writeChunk — rejects bad hash', async (t) => {
483 const { index, transfer } = await setup()
484
485 const receiverDir = tmpDir('receiver')
486 const targetPath = path.join(receiverDir, 'bad.bin')
487
488 await transfer.startReceive(targetPath, {
489 size: 10,
490 chunks: [{ hash: 'a'.repeat(64), offset: 0, length: 10 }]
491 })
492
493 const badData = Buffer.from('wrong data')
494 const result = transfer.writeChunk(targetPath, 0, badData)
495 t.is(result.ok, false)
496 t.ok(result.error.includes('hash mismatch'))
497
498 transfer.cancel(targetPath)
499 await index.close()
500})
501
502// ── Cancel + cleanup ──────────────────────────────────────────
503
504test('cancel removes partial file', async (t) => {
505 const { index, transfer } = await setup()
506
507 const receiverDir = tmpDir('receiver')
508 const targetPath = path.join(receiverDir, 'cancel.bin')
509
510 const state = await transfer.startReceive(targetPath, {
511 size: 100,
512 chunks: [{ hash: 'h1', offset: 0, length: 100 }]
513 })
514
515 t.ok(fs.existsSync(state.partialPath), 'partial exists')
516
517 transfer.cancel(targetPath)
518
519 t.is(fs.existsSync(state.partialPath), false, 'partial cleaned up')
520 t.is(transfer.getProgress(targetPath), null, 'no active transfer')
521
522 await index.close()
523})
524
525test('finalize rejects incomplete transfer', async (t) => {
526 const { index, transfer } = await setup()
527
528 const receiverDir = tmpDir('receiver')
529 const targetPath = path.join(receiverDir, 'incomplete.bin')
530
531 await transfer.startReceive(targetPath, {
532 size: 200,
533 chunks: [
534 { hash: 'h1', offset: 0, length: 100 },
535 { hash: 'h2', offset: 100, length: 100 }
536 ]
537 })
538
539 const result = await transfer.finalize(targetPath)
540 t.is(result.ok, false)
541 t.ok(result.error.includes('incomplete'))
542
543 transfer.cancel(targetPath)
544 await index.close()
545})
546
547// ── listActive ────────────────────────────────────────────────
548
549test('listActive shows active transfers', async (t) => {
550 const { index, transfer } = await setup()
551
552 t.is(transfer.listActive().length, 0)
553
554 const dir = tmpDir('receiver')
555 await transfer.startReceive(path.join(dir, 'a.bin'), { size: 100, chunks: [{ hash: 'h1', offset: 0, length: 100 }] })
556 await transfer.startReceive(path.join(dir, 'b.bin'), { size: 200, chunks: [{ hash: 'h2', offset: 0, length: 200 }] })
557
558 const active = transfer.listActive()
559 t.is(active.length, 2)
560
561 transfer.cancel(path.join(dir, 'a.bin'))
562 transfer.cancel(path.join(dir, 'b.bin'))
563 await index.close()
564})
565
566// ── cleanPartials ─────────────────────────────────────────────
567
568test('cleanPartials removes old partial files', async (t) => {
569 const { transfer } = await setup()
570 const dir = tmpDir('partials')
571
572 // Create a fake stale partial
573 const partial = path.join(dir, '.stale.txt.overlay-partial')
574 fs.writeFileSync(partial, 'stale data')
575
576 // Set mtime to 48 hours ago
577 const old = new Date(Date.now() - 48 * 60 * 60 * 1000)
578 fs.utimesSync(partial, old, old)
579
580 const cleaned = transfer.cleanPartials(dir, 86400000)
581 t.is(cleaned.length, 1)
582 t.is(fs.existsSync(partial), false)
583})
584
585// ── Phase 1/2: block-read prepareFile stays byte-exact and fd-safe ──
586
587test('prepareFile — content hash and chunk map match the buffer-mode reference', async (t) => {
588 const { index, transfer } = await setup()
589 const dir = tmpDir('equiv')
590 const data = crypto.randomBytes(10 * 1024 * 1024) // > read block → exercises multi-block read
591 const filePath = writeTestFile(dir, 'equiv.bin', data)
592
593 const result = await transfer.prepareFile(filePath, '/equiv.bin')
594 const tier = selectTier(data.length)
595 const ref = chunkBuffer(data, { tier })
596
597 t.is(result.contentHash, hashChunk(data), 'content hash equals one-shot whole-file hash')
598 t.is(result.chunks.length, ref.length, 'same chunk count as buffer-mode chunker')
599 for (let i = 0; i < ref.length; i++) {
600 t.is(result.chunks[i].hash, ref[i].hash, 'chunk ' + i + ' hash')
601 t.is(result.chunks[i].offset, ref[i].offset, 'chunk ' + i + ' offset')
602 t.is(result.chunks[i].length, ref[i].length, 'chunk ' + i + ' length')
603 }
604
605 const byHash = await index.getChunkMapByHash(result.contentHash)
606 t.is(byHash.length, ref.length, 'persisted content-addressed map matches')
607
608 await index.close()
609})
610
611test('prepareFile — aborting mid-stream rejects and leaks no fd', async (t) => {
612 const { index, transfer } = await setup()
613 const dir = tmpDir('fdleak')
614 const big = writeTestFile(dir, 'big.bin', crypto.randomBytes(10 * 1024 * 1024))
615
616 // aborted runs throw before any persist, isolating the reader's fd lifecycle
617 await transfer.prepareFile(big, '/warm', { byHashOnly: true, signal: { aborted: true } }).catch(() => {})
618 const ITERS = 25
619 const before = fdCount()
620 for (let i = 0; i < ITERS; i++) {
621 let code = null
622 try { await transfer.prepareFile(big, '/abort-' + i, { byHashOnly: true, signal: { aborted: true } }) } catch (e) { code = e.code }
623 t.is(code, 'ECANCELLED', 'abort ' + i + ' rejects with ECANCELLED')
624 }
625 const after = fdCount()
626 // fdCount() reads the whole-process /dev/fd table, so under `brittle-bare -j` this
627 // snapshot also catches fds opened by sibling test files sharing the process (and the
628 // exact count shifts with the Bare runtime's async scheduling). A genuine leak in the
629 // reader grows ~1 fd per abort (≈ITERS); assert well under that so concurrent-test
630 // noise can't cause a false positive while a real per-iteration leak still fails.
631 if (before === -1) t.pass('fd dir unavailable on this platform — count assertion skipped')
632 else t.ok(after - before < ITERS / 2, `fd count stable after ${ITERS} aborts (${before} -> ${after})`)
633
634 await index.close()
635})
636
637// ── Async/journaled resume (resume-rehash-freeze fix) ─────────
638
639test('REGRESSION (FIX-A): journal-less resume verifies via async I/O, not blocking readSync', async (t) => {
640 const { index, transfer, journalDir } = await setup()
641 const senderPath = writeTestFile(tmpDir('sender'), 'freeze.bin', crypto.randomBytes(256 * 1024))
642 const prepared = await transfer.prepareFile(senderPath, '/freeze.bin')
643 t.ok(prepared.chunks.length > 4, `${prepared.chunks.length} chunks`)
644 const targetPath = path.join(tmpDir('receiver'), 'freeze.bin')
645 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
646 for (let i = 0; i < prepared.chunks.length; i++) {
647 const c = prepared.chunks[i]
648 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
649 }
650 await transfer.pause(targetPath)
651 wipeJournals(journalDir) // model a lost/pre-upgrade journal → force the async re-verify
652
653 // The fix replaced a synchronous fs.readSync verify loop (which froze the worker
654 // event loop) with an async, yielding fs.read pass. Proven deterministically by
655 // the read primitive used: zero blocking readSync, at least one async read.
656 const realRead = fs.read
657 const realReadSync = fs.readSync
658 let asyncReads = 0
659 let syncReads = 0
660 fs.read = (...a) => { asyncReads++; return realRead(...a) }
661 fs.readSync = (...a) => { syncReads++; return realReadSync(...a) }
662 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
663 fs.read = realRead
664 fs.readSync = realReadSync
665 t.is(syncReads, 0, 'no blocking readSync during the resume')
666 t.ok(asyncReads >= prepared.chunks.length, 'each chunk verified via async fs.read')
667 transfer.cancel(targetPath)
668 await index.close()
669})
670
671test('REGRESSION (FIX-A): journal-less resume aborts on isCancelled', async (t) => {
672 const { index, transfer, journalDir } = await setup()
673 const senderPath = writeTestFile(tmpDir('sender'), 'cancel.bin', crypto.randomBytes(256 * 1024))
674 const prepared = await transfer.prepareFile(senderPath, '/cancel.bin')
675 const targetPath = path.join(tmpDir('receiver'), 'cancel.bin')
676 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
677 for (let i = 0; i < prepared.chunks.length; i++) {
678 const c = prepared.chunks[i]
679 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
680 }
681 await transfer.pause(targetPath)
682 wipeJournals(journalDir)
683 let calls = 0
684 await t.exception(
685 transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash }, { isCancelled: () => (++calls > 1) }),
686 /cancel/i, 'a mid-scan cancel rejects with ECANCELLED')
687 await index.close()
688})
689
690test('REGRESSION (FIX-A): _recoverPartialAsync reports a 0..1 verify fraction', async (t) => {
691 const { index, transfer, journalDir } = await setup()
692 const senderPath = writeTestFile(tmpDir('sender'), 'frac.bin', crypto.randomBytes(256 * 1024))
693 const prepared = await transfer.prepareFile(senderPath, '/frac.bin')
694 const targetPath = path.join(tmpDir('receiver'), 'frac.bin')
695 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
696 for (let i = 0; i < prepared.chunks.length; i++) {
697 const c = prepared.chunks[i]
698 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
699 }
700 await transfer.pause(targetPath)
701 wipeJournals(journalDir)
702 const seen = []
703 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash }, { onVerifyProgress: (f) => seen.push(f) })
704 t.is(seen[0], 0, 'first report is 0')
705 t.is(seen[seen.length - 1], 1, 'last report is 1')
706 for (let i = 1; i < seen.length; i++) t.ok(seen[i] >= seen[i - 1], 'non-decreasing')
707 transfer.cancel(targetPath)
708 await index.close()
709})
710
711test('FIX-C: journal resume does NOT report a verify fraction (O(1), no scan)', async (t) => {
712 const { index, transfer } = await setup()
713 const senderPath = writeTestFile(tmpDir('sender'), 'nofrac.bin', crypto.randomBytes(256 * 1024))
714 const prepared = await transfer.prepareFile(senderPath, '/nofrac.bin')
715 const targetPath = path.join(tmpDir('receiver'), 'nofrac.bin')
716 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
717 for (let i = 0; i < Math.floor(prepared.chunks.length / 2); i++) {
718 const c = prepared.chunks[i]
719 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
720 }
721 await transfer.pause(targetPath)
722 const seen = []
723 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash }, { onVerifyProgress: (f) => seen.push(f) })
724 t.is(seen.length, 0, 'journal resume skipped the verify scan')
725 transfer.cancel(targetPath)
726 await index.close()
727})
728
729test('REGRESSION (FIX-B): snapshot resume is O(1) and finalizes verified, no re-read', async (t) => {
730 const { index, transfer } = await setup()
731 const original = crypto.randomBytes(256 * 1024)
732 const senderPath = writeTestFile(tmpDir('sender'), 'snap.bin', original)
733 const prepared = await transfer.prepareFile(senderPath, '/snap.bin')
734 const oid = prepared.contentHash
735 const targetPath = path.join(tmpDir('receiver'), 'snap.bin')
736 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
737 const half = Math.floor(prepared.chunks.length / 2)
738 for (let i = 0; i < half; i++) {
739 const c = prepared.chunks[i]
740 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
741 }
742 await transfer.pause(targetPath)
743
744 const realRead = fs.read
745 const realReadSync = fs.readSync
746 let reads = 0
747 fs.read = (...a) => { reads++; return realRead(...a) }
748 fs.readSync = (...a) => { reads++; return realReadSync(...a) }
749 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
750 fs.read = realRead
751 fs.readSync = realReadSync
752 t.is(reads, 0, 'snapshot resume read zero chunks (O(1))')
753 t.is(st.received.size, half, 'received-set restored from the journal')
754
755 for (let i = half; i < prepared.chunks.length; i++) {
756 const c = prepared.chunks[i]
757 t.ok(transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length)).ok)
758 }
759 const fin = await transfer.finalize(targetPath)
760 t.ok(fin.ok, 'resumed transfer finalizes verified via the restored snapshot')
761 t.alike(fs.readFileSync(targetPath), original, 'bytes match')
762 await index.close()
763})
764
765test('REGRESSION (FIX-B): a fresh TransferManager resumes from the on-disk journal', async (t) => {
766 const { index, transfer, journalDir } = await setup()
767 const original = crypto.randomBytes(256 * 1024)
768 const senderPath = writeTestFile(tmpDir('sender'), 'restart.bin', original)
769 const prepared = await transfer.prepareFile(senderPath, '/restart.bin')
770 const oid = prepared.contentHash
771 const targetPath = path.join(tmpDir('receiver'), 'restart.bin')
772 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
773 const half = Math.floor(prepared.chunks.length / 2)
774 for (let i = 0; i < half; i++) {
775 const c = prepared.chunks[i]
776 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
777 }
778 await transfer.pause(targetPath)
779
780 const fresh = new TransferManager(index, { journalDir })
781 const st = await fresh.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
782 t.is(st.received.size, half, 'received-set restored on a brand-new manager')
783 for (let i = half; i < prepared.chunks.length; i++) {
784 const c = prepared.chunks[i]
785 t.ok(fresh.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length)).ok)
786 }
787 t.ok((await fresh.finalize(targetPath)).ok, 'finalizes verified after a simulated restart')
788 t.alike(fs.readFileSync(targetPath), original, 'bytes match')
789 await index.close()
790})
791
792test('REGRESSION (FIX-WIN: fsync on a read-only handle throws on Windows): journal still persists, resume stays O(1)', async (t) => {
793 const { index, transfer, journalDir } = await setup()
794 const original = crypto.randomBytes(256 * 1024)
795 const senderPath = writeTestFile(tmpDir('sender'), 'winfsync.bin', original)
796 const prepared = await transfer.prepareFile(senderPath, '/winfsync.bin')
797 const oid = prepared.contentHash
798 const targetPath = path.join(tmpDir('receiver'), 'winfsync.bin')
799 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
800 const half = Math.floor(prepared.chunks.length / 2)
801 for (let i = 0; i < half; i++) {
802 const c = prepared.chunks[i]
803 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
804 }
805
806 // Windows FlushFileBuffers needs GENERIC_WRITE; fsync on a read-only handle fails EPERM.
807 const realFsync = fs.fsync
808 const realFsyncSync = fs.fsyncSync
809 fs.fsyncSync = () => { const e = new Error('EPERM: operation not permitted, fsync'); e.code = 'EPERM'; throw e }
810 fs.fsync = () => Promise.reject(Object.assign(new Error('EPERM: operation not permitted, fsync'), { code: 'EPERM' }))
811 t.teardown(() => { fs.fsync = realFsync; fs.fsyncSync = realFsyncSync })
812
813 await transfer.pause(targetPath)
814 t.ok(fs.readdirSync(journalDir).some((f) => f.endsWith('.journal')), 'journal persisted despite fsync throwing')
815
816 fs.fsync = realFsync
817 fs.fsyncSync = realFsyncSync
818
819 const seen = []
820 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid }, { onVerifyProgress: (f) => seen.push(f) })
821 t.is(seen.length, 0, 'resume loaded the journal O(1) — no re-verify scan')
822 t.is(st.received.size, half, 'received-set restored from the persisted journal')
823
824 for (let i = half; i < prepared.chunks.length; i++) {
825 const c = prepared.chunks[i]
826 t.ok(transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length)).ok)
827 }
828 t.ok((await transfer.finalize(targetPath)).ok, 'resumed transfer finalizes verified')
829 t.alike(fs.readFileSync(targetPath), original, 'bytes match')
830 await index.close()
831})
832
833test('FIX-B: snapshot resume + out-of-order remainder finalizes', async (t) => {
834 const { index, transfer } = await setup()
835 const original = crypto.randomBytes(256 * 1024)
836 const senderPath = writeTestFile(tmpDir('sender'), 'ooo2.bin', original)
837 const prepared = await transfer.prepareFile(senderPath, '/ooo2.bin')
838 const oid = prepared.contentHash
839 const targetPath = path.join(tmpDir('receiver'), 'ooo2.bin')
840 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
841 const half = Math.floor(prepared.chunks.length / 2)
842 for (let i = 0; i < half; i++) {
843 const c = prepared.chunks[i]
844 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
845 }
846 await transfer.pause(targetPath)
847 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
848 for (let i = prepared.chunks.length - 1; i >= half; i--) {
849 const c = prepared.chunks[i]
850 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
851 }
852 t.ok((await transfer.finalize(targetPath)).ok, 'snapshot frontier handles reordering')
853 t.alike(fs.readFileSync(targetPath), original, 'bytes match')
854 await index.close()
855})
856
857test('REGRESSION (FIX-B): journal trusts the bitmap; manual reverify catches on-disk rot', async (t) => {
858 const { index, transfer } = await setup()
859 const original = crypto.randomBytes(256 * 1024)
860 const senderPath = writeTestFile(tmpDir('sender'), 'p2.bin', original)
861 const prepared = await transfer.prepareFile(senderPath, '/p2.bin')
862 const oid = prepared.contentHash
863 const targetPath = path.join(tmpDir('receiver'), 'p2.bin')
864 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
865 for (let i = 0; i < prepared.chunks.length; i++) {
866 const c = prepared.chunks[i]
867 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
868 }
869 await transfer.pause(targetPath)
870 const fd = fs.openSync(partialFor(targetPath), 'r+')
871 fs.writeSync(fd, Buffer.alloc(64, 0xff), 0, 64, prepared.chunks[2].offset)
872 fs.closeSync(fd)
873
874 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
875 t.ok(st.received.has(2), 'journaled prefix chunk trusted on resume (no re-read)')
876 t.ok((await transfer.finalize(targetPath)).ok, 'automatic stamp does not re-read → accepts the resumed file')
877 const actual = await transfer._hashWholeFileAsync(targetPath, prepared.size)
878 t.not(actual, oid, 'manual re-verify detects the on-disk corruption')
879 await index.close()
880})
881
882test('REGRESSION (FIX-B): a non-binding journal is ignored (falls back to re-verify)', async (t) => {
883 const { index, transfer } = await setup()
884 const original = crypto.randomBytes(256 * 1024)
885 const senderPath = writeTestFile(tmpDir('sender'), 'bind.bin', original)
886 const prepared = await transfer.prepareFile(senderPath, '/bind.bin')
887 const targetPath = path.join(tmpDir('receiver'), 'bind.bin')
888 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
889 for (let i = 0; i < prepared.chunks.length; i++) {
890 const c = prepared.chunks[i]
891 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
892 }
893 await transfer.pause(targetPath)
894 const fd = fs.openSync(partialFor(targetPath), 'r+')
895 fs.writeSync(fd, Buffer.alloc(64, 0xff), 0, 64, prepared.chunks[2].offset)
896 fs.closeSync(fd)
897 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: 'a'.repeat(64) })
898 t.absent(st.received.has(2), 'mismatched-content journal ignored → re-verify re-needs the corrupted chunk')
899 t.ok(st.received.has(0) && st.received.has(1), 'intact chunks still recognized')
900 transfer.cancel(targetPath)
901 await index.close()
902})
903
904test('REGRESSION (FIX-B): journal removed on finalize; cleanJournals drops orphans', async (t) => {
905 const { index, transfer, journalDir } = await setup()
906 const original = crypto.randomBytes(128 * 1024)
907 const senderPath = writeTestFile(tmpDir('sender'), 'jf.bin', original)
908 const prepared = await transfer.prepareFile(senderPath, '/jf.bin')
909 const targetPath = path.join(tmpDir('receiver'), 'jf.bin')
910 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
911 for (let i = 0; i < prepared.chunks.length; i++) {
912 const c = prepared.chunks[i]
913 transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
914 }
915 t.ok((await transfer.finalize(targetPath)).ok)
916 t.is(fs.readdirSync(journalDir).length, 0, 'journal gone after finalize')
917
918 const t2 = path.join(tmpDir('receiver'), 'orphan.bin')
919 await transfer.startReceive(t2, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
920 transfer.writeChunk(t2, 0, transfer.readChunk(senderPath, prepared.chunks[0].offset, prepared.chunks[0].length))
921 await transfer.pause(t2)
922 t.is(fs.readdirSync(journalDir).length, 1, 'journal written on pause')
923 fs.unlinkSync(partialFor(t2))
924 t.is(transfer.cleanJournals(86400000).length, 1, 'cleanJournals removed the orphan')
925 t.is(fs.readdirSync(journalDir).length, 0, 'journal dir empty after sweep')
926 await index.close()
927})
928
929// REGRESSION (FIX-A2: gap-fill freeze): a low resume frontier with a large received
930// tail used to re-hash the whole contiguous run SYNCHRONOUSLY (fs.readSync) inside
931// the gap-filling writeChunk — re-introducing the worker freeze. The hash advance is
932// now a background async pump, so the gap-fill writeChunk does zero synchronous
933// readback; finalize drains the pump and still verifies.
934test('REGRESSION (FIX-A2): a gap-fill that unlocks a large run does not block writeChunk', async (t) => {
935 const { index, transfer } = await setup()
936 const original = crypto.randomBytes(256 * 1024)
937 const senderPath = writeTestFile(tmpDir('sender'), 'gap.bin', original)
938 const prepared = await transfer.prepareFile(senderPath, '/gap.bin')
939 t.ok(prepared.chunks.length > 4, `${prepared.chunks.length} chunks`)
940 const oid = prepared.contentHash
941 const targetPath = path.join(tmpDir('receiver'), 'gap.bin')
942 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
943 // Receive every chunk EXCEPT chunk 0 → frontier stuck at 0 with a large received tail.
944 for (let i = 1; i < prepared.chunks.length; i++) {
945 const c = prepared.chunks[i]
946 t.ok(transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length)).ok)
947 }
948 const c0 = prepared.chunks[0]
949 const data0 = transfer.readChunk(senderPath, c0.offset, c0.length)
950 const realReadSync = fs.readSync
951 let syncReads = 0
952 fs.readSync = (...a) => { syncReads++; return realReadSync(...a) }
953 t.ok(transfer.writeChunk(targetPath, 0, data0).ok)
954 fs.readSync = realReadSync
955 t.is(syncReads, 0, 'gap-fill writeChunk did no synchronous readback (hash advance is async)')
956
957 const fin = await transfer.finalize(targetPath)
958 t.ok(fin.ok, 'finalizes verified after the async hash drain')
959 t.alike(fs.readFileSync(targetPath), original, 'bytes match')
960 await index.close()
961})
962
963// ── B1/B2: persistent fd + in-memory in-order hashing ─────────
964
965const fdCount = () => {
966 try { return fs.readdirSync('/dev/fd').length } catch {}
967 try { return fs.readdirSync('/proc/self/fd').length } catch {}
968 return -1
969}
970
971function deliver (transfer, senderPath, prepared, targetPath, i) {
972 const c = prepared.chunks[i]
973 return transfer.writeChunk(targetPath, i, transfer.readChunk(senderPath, c.offset, c.length))
974}
975
976test('B1: fd count stays flat across a multi-chunk transfer and 20 transfers', async (t) => {
977 const { index, transfer } = await setup()
978 const original = crypto.randomBytes(1024 * 1024)
979 const senderPath = writeTestFile(tmpDir('sender'), 'b1.bin', original)
980 const prepared = await transfer.prepareFile(senderPath, '/b1.bin')
981 t.ok(prepared.chunks.length > 4, `${prepared.chunks.length} chunks`)
982 const oid = prepared.contentHash
983
984 const targetPath = path.join(tmpDir('rx'), 'b1.bin')
985 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
986 const afterStart = fdCount()
987 for (let i = 0; i < prepared.chunks.length; i++) t.ok(deliver(transfer, senderPath, prepared, targetPath, i).ok)
988 const beforeFin = fdCount()
989 // Same process-wide fd-table caveat as the prepareFile abort test: siblings under
990 // `brittle-bare -j` shift the count, so assert far under the ~1-per-chunk a leak
991 // (or the old per-chunk open racing a snapshot) would show.
992 if (afterStart !== -1) t.ok(beforeFin - afterStart < prepared.chunks.length / 2, `fd flat during transfer (${afterStart} -> ${beforeFin})`)
993 t.ok((await transfer.finalize(targetPath)).ok, 'finalize ok — fd closed before rename')
994 t.alike(fs.readFileSync(targetPath), original, 'round-trip byte-exact')
995
996 const before = fdCount()
997 for (let k = 0; k < 20; k++) {
998 const p = path.join(tmpDir('rx-b1-' + k), 'b1.bin')
999 await transfer.startReceive(p, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
1000 for (let i = 0; i < prepared.chunks.length; i++) deliver(transfer, senderPath, prepared, p, i)
1001 t.ok((await transfer.finalize(p)).ok)
1002 }
1003 const after = fdCount()
1004 if (before !== -1) t.ok(after - before < 10, `no fd leak across 20 transfers (${before} -> ${after})`)
1005 await index.close()
1006})
1007
1008test('B1: pause closes the fd; a late chunk is refused codeless; resume completes', async (t) => {
1009 const { index, transfer } = await setup()
1010 const original = crypto.randomBytes(512 * 1024)
1011 const senderPath = writeTestFile(tmpDir('sender'), 'b1p.bin', original)
1012 const prepared = await transfer.prepareFile(senderPath, '/b1p.bin')
1013 const oid = prepared.contentHash
1014 const targetPath = path.join(tmpDir('rx'), 'b1p.bin')
1015
1016 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
1017 const half = Math.floor(prepared.chunks.length / 2)
1018 for (let i = 0; i < half; i++) t.ok(deliver(transfer, senderPath, prepared, targetPath, i).ok)
1019 await transfer.pause(targetPath)
1020 t.is(st.fd, null, 'pause released the persistent fd')
1021 t.is(st.memBytes, 0, 'pause dropped the stash')
1022
1023 const late = deliver(transfer, senderPath, prepared, targetPath, half)
1024 t.absent(late.ok, 'chunk after pause refused')
1025 t.absent(late.code, 'refusal carries no code (scheduler re-assigns, not _fail)')
1026
1027 const st2 = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
1028 t.is(st2.received.size, half, 'resume recovered the written chunks')
1029 for (let i = half; i < prepared.chunks.length; i++) t.ok(deliver(transfer, senderPath, prepared, targetPath, i).ok)
1030 t.ok((await transfer.finalize(targetPath)).ok, 'digest verifies across pause/resume on the unified fd')
1031 t.alike(fs.readFileSync(targetPath), original)
1032 await index.close()
1033})
1034
1035test('B2: in-order delivery hashes from memory — zero read-backs', async (t) => {
1036 const { index, transfer } = await setup()
1037 const original = crypto.randomBytes(512 * 1024)
1038 const senderPath = writeTestFile(tmpDir('sender'), 'b2i.bin', original)
1039 const prepared = await transfer.prepareFile(senderPath, '/b2i.bin')
1040 const targetPath = path.join(tmpDir('rx'), 'b2i.bin')
1041
1042 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
1043 // tight synchronous loop on purpose — protomux delivers frame bursts with no
1044 // microtask boundary between chunks, and the stash must still engage
1045 for (let i = 0; i < prepared.chunks.length; i++) t.ok(deliver(transfer, senderPath, prepared, targetPath, i).ok)
1046 t.is(st.memBytes, 0, 'stash fully drained before finalize')
1047 t.ok((await transfer.finalize(targetPath)).ok, 'memory-fed digest verifies')
1048 t.is(st.stats.readbacks, 0, 'no disk read-back on the in-order path')
1049 t.is(st.stats.stashHits, prepared.chunks.length, 'every chunk consumed from the stash')
1050 t.alike(fs.readFileSync(targetPath), original)
1051 await index.close()
1052})
1053
1054test('B2: reverse-order delivery verifies; gap chunks under the cap come from the stash', async (t) => {
1055 const { index, transfer } = await setup()
1056 const original = crypto.randomBytes(512 * 1024)
1057 const senderPath = writeTestFile(tmpDir('sender'), 'b2r.bin', original)
1058 const prepared = await transfer.prepareFile(senderPath, '/b2r.bin')
1059 t.ok(prepared.chunks.length >= 4)
1060 const targetPath = path.join(tmpDir('rx'), 'b2r.bin')
1061
1062 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
1063 for (let i = prepared.chunks.length - 1; i >= 0; i--) t.ok(deliver(transfer, senderPath, prepared, targetPath, i).ok, `chunk ${i}`)
1064 t.is(st.memBytes, 0, 'stash fully drained before finalize')
1065 t.ok((await transfer.finalize(targetPath)).ok, 'out-of-order digest identical')
1066 t.is(st.stats.readbacks, 0, 'all gap chunks fit the default cap — served from the stash')
1067 t.alike(fs.readFileSync(targetPath), original)
1068 await index.close()
1069})
1070
1071test('B2: zero cap forces the read-back fallback — digest still verifies', async (t) => {
1072 const { index, transfer } = await setup({ memStashBytes: 0 })
1073
1074 const original = crypto.randomBytes(512 * 1024)
1075 const senderPath = writeTestFile(tmpDir('sender'), 'b2c.bin', original)
1076 const prepared = await transfer.prepareFile(senderPath, '/b2c.bin')
1077 const targetPath = path.join(tmpDir('rx'), 'b2c.bin')
1078
1079 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
1080 for (let i = prepared.chunks.length - 1; i >= 0; i--) t.ok(deliver(transfer, senderPath, prepared, targetPath, i).ok)
1081 t.ok((await transfer.finalize(targetPath)).ok, 'read-back path produces the identical digest')
1082 t.is(st.stats.stashHits, 0, 'nothing stashed at cap 0')
1083 t.is(st.stats.readbacks, prepared.chunks.length, 'every chunk read back')
1084 t.alike(fs.readFileSync(targetPath), original)
1085 await index.close()
1086})
1087
1088test('B2: duplicate delivery is idempotent — digest and stash accounting intact', async (t) => {
1089 const { index, transfer } = await setup()
1090 const original = crypto.randomBytes(256 * 1024)
1091 const senderPath = writeTestFile(tmpDir('sender'), 'b2d.bin', original)
1092 const prepared = await transfer.prepareFile(senderPath, '/b2d.bin')
1093 const targetPath = path.join(tmpDir('rx'), 'b2d.bin')
1094
1095 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
1096 // park a far gap chunk and deliver it TWICE — accounting must count it once
1097 const last = prepared.chunks.length - 1
1098 t.ok(deliver(transfer, senderPath, prepared, targetPath, last).ok)
1099 deliver(transfer, senderPath, prepared, targetPath, last)
1100 t.is(st.memBytes, prepared.chunks[last].length, 'duplicate gap delivery counted once')
1101 for (let i = 0; i < prepared.chunks.length; i++) {
1102 t.ok(deliver(transfer, senderPath, prepared, targetPath, i).ok)
1103 deliver(transfer, senderPath, prepared, targetPath, i)
1104 }
1105 t.is(st.memBytes, 0, 'stash accounting drained to zero before finalize')
1106 t.ok((await transfer.finalize(targetPath)).ok, 'duplicates do not corrupt the digest')
1107 t.alike(fs.readFileSync(targetPath), original)
1108 await index.close()
1109})
1110
1111test('B1/B2: cancel clears the fd and the stash', async (t) => {
1112 const { index, transfer } = await setup()
1113 const original = crypto.randomBytes(256 * 1024)
1114 const senderPath = writeTestFile(tmpDir('sender'), 'b1c.bin', original)
1115 const prepared = await transfer.prepareFile(senderPath, '/b1c.bin')
1116 const targetPath = path.join(tmpDir('rx'), 'b1c.bin')
1117
1118 const st = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
1119 // deliver a far gap chunk so an entry is parked in the stash
1120 t.ok(deliver(transfer, senderPath, prepared, targetPath, prepared.chunks.length - 1).ok)
1121 t.ok(st.memBytes > 0, 'gap chunk parked in the stash')
1122 transfer.cancel(targetPath)
1123 t.is(st.fd, null, 'cancel closed the fd')
1124 t.is(st.memBytes, 0, 'cancel cleared the stash')
1125 t.is(st.memChunks.size, 0, 'stash map empty')
1126 t.absent(fs.existsSync(partialFor(targetPath)), 'partial unlinked')
1127 await index.close()
1128})
1129
1130// ── Short writes, rename failure ──────────────────────────────
1131
1132test('a short writeSync is retried to completion — no silent hole', async (t) => {
1133 const { index, transfer } = await setup()
1134 const original = crypto.randomBytes(256 * 1024)
1135 const senderPath = writeTestFile(tmpDir('sender'), 'sw.bin', original)
1136 const prepared = await transfer.prepareFile(senderPath, '/sw.bin')
1137 const targetPath = path.join(tmpDir('rx'), 'sw.bin')
1138 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
1139
1140 // model libuv's error-after-partial-progress: return a short count, no throw
1141 const origWrite = fs.writeSync
1142 let shorted = 0
1143 fs.writeSync = (fd, data, off, len, pos) => {
1144 if (len > 32 && shorted < 3) { shorted++; return origWrite(fd, data, off, len >> 1, pos) }
1145 return origWrite(fd, data, off, len, pos)
1146 }
1147 t.teardown(() => { fs.writeSync = origWrite })
1148 for (let i = 0; i < prepared.chunks.length; i++) t.ok(deliver(transfer, senderPath, prepared, targetPath, i).ok)
1149 fs.writeSync = origWrite
1150 t.ok(shorted > 0, 'short writes were injected')
1151 t.ok((await transfer.finalize(targetPath)).ok, 'digest verifies')
1152 t.alike(fs.readFileSync(targetPath), original, 'no hole despite short writes')
1153 await index.close()
1154})
1155
1156test('a stuck write (0 bytes forever) fails the chunk with a code', async (t) => {
1157 const { index, transfer } = await setup()
1158 const original = crypto.randomBytes(64 * 1024)
1159 const senderPath = writeTestFile(tmpDir('sender'), 'sw0.bin', original)
1160 const prepared = await transfer.prepareFile(senderPath, '/sw0.bin')
1161 const targetPath = path.join(tmpDir('rx'), 'sw0.bin')
1162 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: prepared.contentHash })
1163
1164 const origWrite = fs.writeSync
1165 fs.writeSync = () => 0
1166 t.teardown(() => { fs.writeSync = origWrite })
1167 const res = deliver(transfer, senderPath, prepared, targetPath, 0)
1168 fs.writeSync = origWrite
1169 t.absent(res.ok, 'stuck write fails the chunk')
1170 t.is(res.code, 'EIO', 'carries a code so the scheduler treats it as fatal, not a mismatch')
1171 await index.close()
1172})
1173
1174test('finalize rename failure carries the code, clears the state, and a retry resumes', async (t) => {
1175 const { index, transfer } = await setup()
1176 const original = crypto.randomBytes(256 * 1024)
1177 const senderPath = writeTestFile(tmpDir('sender'), 'rn.bin', original)
1178 const prepared = await transfer.prepareFile(senderPath, '/rn.bin')
1179 const oid = prepared.contentHash
1180 const targetPath = path.join(tmpDir('rx'), 'rn.bin')
1181
1182 await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
1183 for (let i = 0; i < prepared.chunks.length; i++) t.ok(deliver(transfer, senderPath, prepared, targetPath, i).ok)
1184
1185 const origRename = fs.renameSync
1186 fs.renameSync = () => { const e = new Error('no such file or directory'); e.code = 'ENOENT'; throw e }
1187 t.teardown(() => { fs.renameSync = origRename })
1188 const fin = await transfer.finalize(targetPath)
1189 fs.renameSync = origRename
1190 t.absent(fin.ok, 'finalize reports the rename failure')
1191 t.is(fin.code, 'ENOENT', 'the fs error code is surfaced (coded local-I/O classification)')
1192 t.is(transfer.getProgress(targetPath), null, 'no parked state left in _active')
1193 t.ok(fs.existsSync(partialFor(targetPath)), 'partial kept for the retry')
1194
1195 const st2 = await transfer.startReceive(targetPath, { size: prepared.size, chunks: prepared.chunks, contentHash: oid })
1196 t.is(st2.received.size, prepared.chunks.length, 'retry resumes with all chunks recognized')
1197 t.ok((await transfer.finalize(targetPath)).ok, 'retry finalizes')
1198 t.alike(fs.readFileSync(targetPath), original)
1199 await index.close()
1200})
test/integration/treeresponse-wire-paging.test.js +210 -0
……@@ -0,0 +1,210 @@
1// Tree-response wire paging. A directory tree with enough entries, shipped in one
2// treeResponse frame, exceeds the Noise transport's 16 MiB-1 atomic-write limit
3// (@hyperswarm/secret-stream MAX_ATOMIC_WRITE) — the same class of failure the
4// chunkHashes paging fix addressed. The tree path is dormant in gated mode
5// (serveAuthorizer refuses treeRequest and nothing calls requestTree), so this
6// drives the protocol directly, mirroring the chunkHashes paging test.
7import test from 'brittle'
8import * as m from '../../lib/messages.js'
9import { OverlayProtocol } from '../../lib/protocol.js'
10
11const MAX_ATOMIC_WRITE = 256 * 256 * 256 - 1
12
13function encodedSize (msg) {
14 const state = { start: 0, end: 0, buffer: null }
15 m.treeResponse.preencode(state, msg)
16 return state.end
17}
18
19// A tree big enough that one frame blows MAX_ATOMIC_WRITE. Distinct names + sizes let us
20// assert order is preserved across pages; the long name inflates each entry (~240 B).
21function bigTree (count) {
22 const childHash = 'a'.repeat(64)
23 const entries = new Array(count)
24 for (let i = 0; i < count; i++) {
25 entries[i] = { kind: 0, exec: 0, name: 'file-' + i + '-'.repeat(200), childHash, size: i }
26 }
27 return entries
28}
29
30function fakePeer () {
31 return { pendingTrees: new Map(), msgs: { treeRequest: { send () {} } } }
32}
33
34async function rejection (promise) {
35 try { await promise; return null } catch (err) { return err }
36}
37
38test('REGRESSION (FIX-13): a directory tree overflows one frame but pages stay under the limit', (t) => {
39 const entries = bigTree(80000)
40
41 const oneFrame = encodedSize({ hash: 'a'.repeat(64), entries, more: 0, nonce: 0 })
42 t.ok(oneFrame > MAX_ATOMIC_WRITE, `single frame (${oneFrame} B) exceeds the ${MAX_ATOMIC_WRITE} B atomic-write limit`)
43
44 const proto = new OverlayProtocol({}, {}, {})
45 const sent = []
46 const peer = { msgs: { treeResponse: { send: (msg) => sent.push(msg) } } }
47 proto._sendTreeResponse(peer, 'a'.repeat(64), entries, 0)
48
49 t.ok(sent.length > 1, `paged into ${sent.length} frames`)
50 for (const f of sent) {
51 // 64 KiB headroom covers the Protomux header + secret-stream ABYTES.
52 t.ok(encodedSize(f) <= MAX_ATOMIC_WRITE - 65536, `page frame ${encodedSize(f)} B fits under the limit`)
53 }
54 for (let i = 0; i < sent.length; i++) {
55 t.is(sent[i].more, i === sent.length - 1 ? 0 : 1, `page ${i} more flag`)
56 t.is(sent[i].hash, 'a'.repeat(64), 'hash repeated on every page')
57 }
58 const rebuilt = sent.flatMap((f) => f.entries)
59 t.is(rebuilt.length, entries.length, 'no entries lost across pages')
60 t.is(rebuilt[0].name, entries[0].name, 'first entry preserved')
61 t.is(rebuilt[rebuilt.length - 1].name, entries[entries.length - 1].name, 'last entry preserved')
62})
63
64test('FIX-13: the receiver reassembles paged frames and resolves requestTree once', async (t) => {
65 const entries = bigTree(40000)
66 const proto = new OverlayProtocol({}, {}, {})
67 const hash = 'a'.repeat(64)
68 const peer = fakePeer()
69 const p = proto.requestTree(peer, hash, { timeout: 5000 })
70 const nonce = peer.pendingTrees.get(hash).nonce
71
72 // Page on the send side (echoing the request nonce), then feed the frames back.
73 const sent = []
74 const sender = { msgs: { treeResponse: { send: (msg) => sent.push(msg) } } }
75 proto._sendTreeResponse(sender, hash, entries, nonce)
76 t.ok(sent.length > 1, 'multiple pages to reassemble')
77 for (const f of sent) proto._onTreeResponse(peer, f)
78
79 const got = await p
80 t.is(got.length, entries.length, 'full tree reassembled in order')
81 t.is(got[entries.length - 1].name, entries[entries.length - 1].name, 'last entry intact')
82 t.absent(peer.pendingTrees.has(hash), 'pending request + its page buffer cleared')
83})
84
85test('FIX-13: a small tree still ships as one frame and resolves immediately', async (t) => {
86 const entries = [{ kind: 0, exec: 0, name: 'a.js', childHash: 'b'.repeat(64), size: 1 }]
87 const proto = new OverlayProtocol({}, {}, {})
88 const hash = 'c'.repeat(64)
89 const peer = fakePeer()
90 const p = proto.requestTree(peer, hash, { timeout: 5000 })
91 const nonce = peer.pendingTrees.get(hash).nonce
92
93 const sent = []
94 const sender = { msgs: { treeResponse: { send: (msg) => sent.push(msg) } } }
95 proto._sendTreeResponse(sender, hash, entries, nonce)
96 t.is(sent.length, 1, 'single frame for a small tree')
97 t.is(sent[0].more, 0, 'lone frame is final')
98
99 proto._onTreeResponse(peer, sent[0])
100 t.alike(await p, entries, 'lone complete frame resolves the request')
101})
102
103test('FIX-13: an empty tree ships as one final frame', (t) => {
104 const proto = new OverlayProtocol({}, {}, {})
105 const sent = []
106 const peer = { msgs: { treeResponse: { send: (msg) => sent.push(msg) } } }
107 proto._sendTreeResponse(peer, 'd'.repeat(64), [], 0)
108 t.is(sent.length, 1, 'single frame for an empty tree')
109 t.is(sent[0].more, 0, 'lone frame is final')
110 t.alike(sent[0].entries, [], 'no entries')
111})
112
113test('FIX-13: pages for two trees interleaved on one channel reassemble independently', async (t) => {
114 const proto = new OverlayProtocol({}, {}, {})
115 const hA = 'a'.repeat(64)
116 const hB = 'b'.repeat(64)
117 const peer = fakePeer()
118 const pA = proto.requestTree(peer, hA, { timeout: 5000 })
119 const pB = proto.requestTree(peer, hB, { timeout: 5000 })
120 const nA = peer.pendingTrees.get(hA).nonce
121 const nB = peer.pendingTrees.get(hB).nonce
122
123 const a1 = { kind: 0, exec: 0, name: 'a1', childHash: hA, size: 1 }
124 const a2 = { kind: 0, exec: 0, name: 'a2', childHash: hA, size: 2 }
125 const b1 = { kind: 0, exec: 0, name: 'b1', childHash: hB, size: 3 }
126 const b2 = { kind: 0, exec: 0, name: 'b2', childHash: hB, size: 4 }
127
128 proto._onTreeResponse(peer, { hash: hA, entries: [a1], more: 1, nonce: nA })
129 proto._onTreeResponse(peer, { hash: hB, entries: [b1], more: 1, nonce: nB })
130 proto._onTreeResponse(peer, { hash: hA, entries: [a2], more: 0, nonce: nA })
131 proto._onTreeResponse(peer, { hash: hB, entries: [b2], more: 0, nonce: nB })
132
133 t.alike(await pA, [a1, a2], 'tree A reassembled from its own pages')
134 t.alike(await pB, [b1, b2], 'tree B reassembled from its own pages')
135})
136
137test('FIX-13: an oversized paged tree response is rejected at the byte cap', async (t) => {
138 const proto = new OverlayProtocol({}, {}, { maxTreeResponseBytes: 4096 })
139 const hash = 'e'.repeat(64)
140 const peer = fakePeer()
141 const p = proto.requestTree(peer, hash, { timeout: 5000 })
142 const nonce = peer.pendingTrees.get(hash).nonce
143
144 // ~49 B/entry × 50 ≈ 2450 B/page; two pages exceed the 4096 B cap.
145 const page = () => Array.from({ length: 50 }, (_, i) => ({ kind: 0, exec: 0, name: 'n' + i, childHash: 'a'.repeat(64), size: i }))
146 proto._onTreeResponse(peer, { hash, entries: page(), more: 1, nonce })
147 proto._onTreeResponse(peer, { hash, entries: page(), more: 1, nonce })
148
149 const err = await rejection(p)
150 t.ok(err && /tree response too large/.test(err.message), 'rejects once accumulated bytes exceed the cap')
151 t.absent(peer.pendingTrees.has(hash), 'pending + buffer cleared on cap rejection')
152})
153
154test('FIX-13: a stale page from a superseded request cannot settle a same-hash retry', async (t) => {
155 const proto = new OverlayProtocol({}, {}, {})
156 const hash = 'f'.repeat(64)
157 const peer = fakePeer()
158 const stale = { kind: 0, exec: 0, name: 'stale', childHash: hash, size: 1 }
159
160 // Request #1 buffers a page, then is force-failed (as a timeout would) → caller retries.
161 const p1 = proto.requestTree(peer, hash, { timeout: 5000 })
162 const n1 = peer.pendingTrees.get(hash).nonce
163 proto._onTreeResponse(peer, { hash, entries: [stale], more: 1, nonce: n1 })
164 proto._failPendingTrees(peer, new Error('timed out'))
165 t.ok(await rejection(p1), '#1 rejected')
166
167 // Retry (#2) gets a distinct nonce.
168 const p2 = proto.requestTree(peer, hash, { timeout: 5000 })
169 const n2 = peer.pendingTrees.get(hash).nonce
170 t.not(n1, n2, 'retry gets a distinct nonce')
171
172 // #1's in-flight FINAL page arrives now — must be DROPPED (stale nonce), not resolve #2.
173 proto._onTreeResponse(peer, { hash, entries: [stale], more: 0, nonce: n1 })
174 t.ok(peer.pendingTrees.has(hash), '#2 still pending — stale final page did not settle it')
175
176 // #2's own real pages resolve it correctly.
177 const real = [
178 { kind: 0, exec: 0, name: 'real-a', childHash: hash, size: 2 },
179 { kind: 0, exec: 0, name: 'real-b', childHash: hash, size: 3 }
180 ]
181 proto._onTreeResponse(peer, { hash, entries: [real[0]], more: 1, nonce: n2 })
182 proto._onTreeResponse(peer, { hash, entries: [real[1]], more: 0, nonce: n2 })
183 t.alike(await p2, real, '#2 resolves with only its own entries')
184})
185
186test('FIX-13: a pre-nonce holder (nonce 0) is accepted best-effort', async (t) => {
187 const proto = new OverlayProtocol({}, {}, {})
188 const hash = 'ab'.repeat(32)
189 const peer = fakePeer()
190 const p = proto.requestTree(peer, hash, { timeout: 5000 })
191 const entries = [{ kind: 0, exec: 0, name: 'x', childHash: hash, size: 1 }]
192 // nonce 0 = an old holder that can't echo the request id → unverifiable, still accepted.
193 proto._onTreeResponse(peer, { hash, entries, more: 0, nonce: 0 })
194 t.alike(await p, entries, 'a lone frame from a pre-nonce holder still resolves')
195})
196
197test('FIX-13: pending tree requests are rejected promptly when the peer is torn down', async (t) => {
198 const proto = new OverlayProtocol({}, {}, {})
199 const hash = 'a1'.repeat(32)
200 const peer = fakePeer()
201 const p = proto.requestTree(peer, hash, { timeout: 60000 })
202 const nonce = peer.pendingTrees.get(hash).nonce
203 proto._onTreeResponse(peer, { hash, entries: [{ kind: 0, exec: 0, name: 'x', childHash: hash, size: 1 }], more: 1, nonce })
204 t.ok(peer.pendingTrees.has(hash), 'buffered mid-stream')
205
206 proto._failPendingTrees(peer, new Error('channel closed'))
207 const err = await rejection(p)
208 t.ok(err && /channel closed/.test(err.message), 'rejects on close, not after the 60s timeout')
209 t.absent(peer.pendingTrees.has(hash), 'pending + buffer cleared on close')
210})