| … | … | @@ -493,10 +493,13 @@ export class TransferManager { |
| 493 | 493 | try { buf = this._encodeJournal(state) } catch { break } |
| 494 | 494 | const tmp = state.journalPath + '.tmp' |
| 495 | 495 | 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 {} | |
| 500 | 503 | await fs.writeFile(tmp, buf) |
| 501 | 504 | if (state.fd == null) break // paused/cancelled mid-flush — don't re-create the journal |
| 502 | 505 | await fs.rename(tmp, state.journalPath) |
| … | … | @@ -512,10 +515,9 @@ export class TransferManager { |
| 512 | 515 | if (!state.contentHash || !state.journalPath) return |
| 513 | 516 | let buf |
| 514 | 517 | 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 {} } | |
| 515 | 520 | 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) | |
| 519 | 521 | const tmp = state.journalPath + '.tmp' |
| 520 | 522 | fs.writeFileSync(tmp, buf) |
| 521 | 523 | fs.renameSync(tmp, state.journalPath) |
| … | … | @@ -42,7 +42,7 @@ |
| 42 | 42 | "./package.json": "./package.json" |
| 43 | 43 | }, |
| 44 | 44 | "scripts": { |
| 45 | "test": "brittle-bare test/*.test.js", | |
| 45 | "test": "brittle-bare test/*.test.js test/integration/*.test.js", | |
| 46 | 46 | "test:node": "brittle-node test/*.test.js" |
| 47 | 47 | }, |
| 48 | 48 | "engines": { |
| … | … | @@ -0,0 +1,125 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { 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 | ||
| 10 | const tick = () => new Promise((r) => setTimeout(r, 0)) | |
| 11 | ||
| 12 | function 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'. | |
| 25 | function 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 | ||
| 47 | const map3 = () => [ | |
| 48 | { hash: 'a', offset: 0, length: 4 }, | |
| 49 | { hash: 'b', offset: 4, length: 4 }, | |
| 50 | { hash: 'c', offset: 8, length: 4 }, | |
| 51 | ] | |
| 52 | ||
| 53 | test('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 | ||
| 70 | test('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 | ||
| 85 | test('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 | ||
| 97 | test('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 | ||
| 110 | test('_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 | }) |
| … | … | @@ -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. | |
| 10 | import test from 'brittle' | |
| 11 | import * as m from '../../lib/messages.js' | |
| 12 | import { 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. | |
| 17 | const MAX_ATOMIC_WRITE = 256 * 256 * 256 - 1 | |
| 18 | ||
| 19 | function 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). | |
| 24 | function 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). | |
| 34 | function 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 | ||
| 41 | test('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 | ||
| 74 | test('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 | ||
| 104 | test('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 | ||
| 121 | test('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 | }) |
| … | … | @@ -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. | |
| 7 | import test from 'brittle' | |
| 8 | import { tmpStore } from '../helpers.js' | |
| 9 | import { FileIndex } from '../../lib/file-index.js' | |
| 10 | ||
| 11 | const 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. | |
| 15 | function 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 | ||
| 26 | async function setup () { | |
| 27 | const store = tmpStore('chunkmap-paging') | |
| 28 | const index = new FileIndex(store) | |
| 29 | await index.ready() | |
| 30 | return index | |
| 31 | } | |
| 32 | ||
| 33 | test('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 | ||
| 47 | test('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 | ||
| 59 | test('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 | ||
| 70 | test('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 | ||
| 85 | test('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 | ||
| 100 | test('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 | }) |
| … | … | @@ -0,0 +1,140 @@ |
| 1 | import test from 'brittle' | |
| 2 | import Protomux from 'protomux' | |
| 3 | import { Duplex } from 'streamx' | |
| 4 | import crypto from 'hypercore-crypto' | |
| 5 | import { tmpStore, tmpDir, fs, path } from '../helpers.js' | |
| 6 | import { HyperOverlay } from '../../lib/overlay.js' | |
| 7 | ||
| 8 | // In-memory duplex pair (same pattern as e2e-two-peers.js). | |
| 9 | function 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 | ||
| 18 | async 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 | ||
| 27 | test('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 | ||
| 63 | test('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 | ||
| 99 | test('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 | ||
| 135 | test('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 | }) |
| … | … | @@ -0,0 +1,482 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { tmpStore } from '../helpers.js' | |
| 3 | import { FileIndex } from '../../lib/file-index.js' | |
| 4 | ||
| 5 | // ── File entries ────────────────────────────────────────────── | |
| 6 | ||
| 7 | test('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 | ||
| 29 | test('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 | ||
| 47 | test('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 | ||
| 62 | test('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 | ||
| 71 | test('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 | ||
| 82 | test('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 | ||
| 100 | test('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 | ||
| 121 | test('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 | ||
| 145 | test('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 | ||
| 154 | test('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 | ||
| 165 | test('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 | ||
| 182 | test('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 | ||
| 199 | test('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 | ||
| 208 | test('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 | ||
| 228 | test('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 | ||
| 245 | test('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 | ||
| 268 | test('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 | ||
| 279 | test('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 | ||
| 298 | test('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 | ||
| 327 | function fakeHash (byte) { | |
| 328 | return Buffer.alloc(32, byte).toString('hex') | |
| 329 | } | |
| 330 | ||
| 331 | function makeTree (size, entries = []) { | |
| 332 | return { entries, size } | |
| 333 | } | |
| 334 | ||
| 335 | test('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 | ||
| 358 | test('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 | ||
| 365 | test('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 | ||
| 376 | test('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 | ||
| 386 | test('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 | ||
| 401 | test('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 | ||
| 416 | test('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 | ||
| 442 | test('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 | ||
| 459 | test('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 | }) |
| … | … | @@ -0,0 +1,66 @@ |
| 1 | import test from 'brittle' | |
| 2 | import Protomux from 'protomux' | |
| 3 | import crypto from 'hypercore-crypto' | |
| 4 | import { Duplex } from 'streamx' | |
| 5 | import { tmpStore, tmpDir, fs, path } from '../helpers.js' | |
| 6 | import { 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 | ||
| 14 | function 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 | } | |
| 22 | const settle = (ms = 400) => new Promise(r => setTimeout(r, ms)) | |
| 23 | ||
| 24 | test('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 | }) |
| … | … | @@ -0,0 +1,172 @@ |
| 1 | import test from 'brittle' | |
| 2 | import fs from 'bare-fs' | |
| 3 | import os from 'bare-os' | |
| 4 | import path from 'bare-path' | |
| 5 | import { 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. | |
| 10 | function fakeTransfer () { | |
| 11 | return { startReceive () { return { received: new Set() } }, writeChunk () { return { ok: true } }, finalize () { return { ok: true } }, cancel () {}, pause () {} } | |
| 12 | } | |
| 13 | const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'p2-')) | |
| 14 | ||
| 15 | test('#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 | ||
| 22 | test('#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 | ||
| 39 | test('#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 | ||
| 48 | test('#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 | ||
| 63 | function 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. | |
| 68 | function seedScheduler (proto, contentHash) { | |
| 69 | proto._schedulers.set('content:' + contentHash, { destPath: '/x', cancel () {} }) | |
| 70 | } | |
| 71 | ||
| 72 | test('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 | ||
| 81 | test('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 | ||
| 90 | test('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 | ||
| 99 | test('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 | ||
| 107 | test('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 | ||
| 115 | test('_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 | ||
| 126 | test('_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 | ||
| 133 | test('_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 | ||
| 143 | test('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 | ||
| 151 | test('_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 | ||
| 160 | test('_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 | ||
| 167 | test('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 | }) |
| … | … | @@ -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). | |
| 5 | import test from 'brittle' | |
| 6 | import Protomux from 'protomux' | |
| 7 | import crypto from 'hypercore-crypto' | |
| 8 | import { Duplex } from 'streamx' | |
| 9 | import { tmpStore, tmpDir, fs, path } from '../helpers.js' | |
| 10 | import { 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 | ||
| 22 | function 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 | ||
| 31 | const settle = (ms = 400) => new Promise(r => setTimeout(r, ms)) | |
| 32 | ||
| 33 | test('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 | }) |
| … | … | @@ -0,0 +1,108 @@ |
| 1 | import test from 'brittle' | |
| 2 | import Protomux from 'protomux' | |
| 3 | import crypto from 'hypercore-crypto' | |
| 4 | import { Duplex } from 'streamx' | |
| 5 | import { tmpStore, tmpDir, fs, path } from '../helpers.js' | |
| 6 | import { HyperOverlay } from '../../lib/overlay.js' | |
| 7 | import { 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. | |
| 14 | function 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 | } | |
| 22 | const settle = (ms = 800) => new Promise((r) => setTimeout(r, ms)) | |
| 23 | const MEMBER = 'a'.repeat(64) | |
| 24 | ||
| 25 | test('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 | ||
| 76 | test('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 | }) |
| … | … | @@ -0,0 +1,73 @@ |
| 1 | import test from 'brittle' | |
| 2 | import Protomux from 'protomux' | |
| 3 | import crypto from 'hypercore-crypto' | |
| 4 | import { Duplex } from 'streamx' | |
| 5 | import { tmpStore, tmpDir, fs, path } from '../helpers.js' | |
| 6 | import { HyperOverlay } from '../../lib/overlay.js' | |
| 7 | ||
| 8 | function 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 | } | |
| 16 | const 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. | |
| 25 | test('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 | ||
| 60 | test('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 | }) |
| … | … | @@ -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. | |
| 6 | import test from 'brittle' | |
| 7 | import Protomux from 'protomux' | |
| 8 | import crypto from 'hypercore-crypto' | |
| 9 | import { Duplex } from 'streamx' | |
| 10 | import { tmpStore, tmpDir, fs, path } from '../helpers.js' | |
| 11 | import { HyperOverlay } from '../../lib/overlay.js' | |
| 12 | ||
| 13 | function 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 | ||
| 22 | const settle = (ms = 400) => new Promise(r => setTimeout(r, ms)) | |
| 23 | const MEMBER = 'a'.repeat(64) | |
| 24 | const STRANGER = 'b'.repeat(64) | |
| 25 | ||
| 26 | test('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 | }) |
| … | … | @@ -0,0 +1,346 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { tmpStore } from '../helpers.js' | |
| 3 | import { FileIndex } from '../../lib/file-index.js' | |
| 4 | import { SyncEngine } from '../../lib/sync-engine.js' | |
| 5 | ||
| 6 | async 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 | ||
| 17 | test('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 | ||
| 34 | test('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 | ||
| 50 | test('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 | ||
| 61 | test('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 | ||
| 83 | test('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 | ||
| 102 | test('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 | ||
| 120 | test('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 | ||
| 144 | test('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 | ||
| 163 | test('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 | ||
| 183 | test('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 | ||
| 206 | test('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 | ||
| 228 | test('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 | ||
| 252 | test('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 | ||
| 264 | test('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 | ||
| 276 | test('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 | ||
| 288 | test('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 | ||
| 299 | test('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 | ||
| 311 | test('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 | ||
| 325 | test('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 | ||
| 335 | test('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 | }) |
| … | … | @@ -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. | |
| 4 | import test from 'brittle' | |
| 5 | import { tmpStore, tmpDir, fs, path } from '../helpers.js' | |
| 6 | import { FileIndex } from '../../lib/file-index.js' | |
| 7 | import { TransferManager } from '../../lib/transfer.js' | |
| 8 | import { hashChunk, selectTier, chunk as chunkBuffer } from '../../lib/chunker.js' | |
| 9 | import crypto from 'hypercore-crypto' | |
| 10 | ||
| 11 | async 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 | ||
| 20 | function wipeJournals (journalDir) { | |
| 21 | for (const f of fs.readdirSync(journalDir)) fs.unlinkSync(path.join(journalDir, f)) | |
| 22 | } | |
| 23 | ||
| 24 | function 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 | ||
| 33 | test('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 | ||
| 61 | test('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 | ||
| 83 | test('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 | ||
| 98 | test('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 | ||
| 107 | test('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 | ||
| 136 | test('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 | ||
| 150 | test('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 | ||
| 167 | test('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 | ||
| 183 | test('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 | ||
| 190 | test('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 | ||
| 206 | test('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 | ||
| 218 | test('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 | ||
| 232 | test('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 | ||
| 276 | test('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 | ||
| 307 | test('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. | |
| 339 | test('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 | ||
| 359 | test('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 | ||
| 379 | test('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 | ||
| 401 | function partialFor (targetPath) { | |
| 402 | return path.join(path.dirname(targetPath), path.basename(targetPath) + '.overlay-partial') | |
| 403 | } | |
| 404 | ||
| 405 | test('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 | ||
| 437 | test('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 | ||
| 444 | test('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 | ||
| 469 | test('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 | ||
| 482 | test('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 | ||
| 504 | test('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 | ||
| 525 | test('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 | ||
| 549 | test('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 | ||
| 568 | test('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 | ||
| 587 | test('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 | ||
| 611 | test('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 | ||
| 639 | test('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 | ||
| 671 | test('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 | ||
| 690 | test('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 | ||
| 711 | test('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 | ||
| 729 | test('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 | ||
| 765 | test('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 | ||
| 792 | test('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 | ||
| 833 | test('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 | ||
| 857 | test('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 | ||
| 882 | test('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 | ||
| 904 | test('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. | |
| 934 | test('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 | ||
| 965 | const 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 | ||
| 971 | function 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 | ||
| 976 | test('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 | ||
| 1008 | test('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 | ||
| 1035 | test('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 | ||
| 1054 | test('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 | ||
| 1071 | test('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 | ||
| 1088 | test('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 | ||
| 1111 | test('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 | ||
| 1132 | test('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 | ||
| 1156 | test('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 | ||
| 1174 | test('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 | }) |
| … | … | @@ -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. | |
| 7 | import test from 'brittle' | |
| 8 | import * as m from '../../lib/messages.js' | |
| 9 | import { OverlayProtocol } from '../../lib/protocol.js' | |
| 10 | ||
| 11 | const MAX_ATOMIC_WRITE = 256 * 256 * 256 - 1 | |
| 12 | ||
| 13 | function 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). | |
| 21 | function 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 | ||
| 30 | function fakePeer () { | |
| 31 | return { pendingTrees: new Map(), msgs: { treeRequest: { send () {} } } } | |
| 32 | } | |
| 33 | ||
| 34 | async function rejection (promise) { | |
| 35 | try { await promise; return null } catch (err) { return err } | |
| 36 | } | |
| 37 | ||
| 38 | test('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 | ||
| 64 | test('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 | ||
| 85 | test('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 | ||
| 103 | test('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 | ||
| 113 | test('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 | ||
| 137 | test('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 | ||
| 154 | test('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 | ||
| 186 | test('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 | ||
| 197 | test('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 | }) |