OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / 0b71696

port(0.3.0): apply hardening-audit fixes F1/F2/F3 (crash-safety edges)
From the correctness audit vs Keet/hypercore. Shipping gated path was already correct; these close latent ungated/adversarial edges: - F1 (_loadJournal): return null when meta.contentHash is absent. finalize + the legacy receive both skip the whole-file hash without a contentHash, so a FOREIGN same-size/same-chunk-count journal would be trusted -> merged old+new bytes, undetected. Now a journal is only trusted when we can whole-file-verify it; else fall back to per-chunk re-verification. Only true silent-corruption path, closed. - F2 (protocol _reassembleChunkHashes): add MAX_CHUNK_HASHES_ACCUM cap (~16M, >> any real file) so an authorized-but-rogue seeder streaming more:1 forever cannot OOM (each page re-armed the idle watchdog). Also clear per-peer _chunkHashPages on scheduler teardown so a same-key content:<hash> retry cannot append onto a stale partial page list. - F3 (transfer flush, async+sync): a failed fsync now skips the journal commit instead of swallowing the error and committing anyway (EIO write-back hazard) — never claim chunks durable that the fsync did not persist. Suite green in isolation; the multi-source peerCount race under full-suite load is a separate request-side flow-control gap (addressed next by Keet-grade P2 window). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0b716964182e8310b3e6b78b2edbbb7863b3f1d8
by plankatron <fleeky@gmail.com> · 2026-07-11T18:56:02.000Z

Changed files (2) +37 -4

lib/protocol.js +21 -1
……@@ -33,6 +33,14 @@ const CAP_ADAPTIVE_CHUNKS = 0x02
3333// ≤8 MiB send-batching.
3434const MAX_CHUNKS_PER_MSG = 100000
3535
36// F2: hard ceiling on chunk-hash entries accumulated across a paged chunkHashes
37// stream. Far above any real file's chunk count (a 1.25 TB tier-3 file is ~1.2M
38// entries), so it never trips legitimately — but it bounds an authorized-but-rogue
39// seeder that streams `more:1` pages forever (each buffered page re-arms the idle
40// watchdog, so the stall timer never fires). Unlike treeResponse, chunkHashes had
41// no such cap. ~16M entries ≈ a few hundred MB worst case before we drop the list.
42const MAX_CHUNK_HASHES_ACCUM = 16 * 1024 * 1024
43
3644// Tree entries are variable length (name is a var-string), unlike the fixed-width
3745// chunkHashes entries, so treeResponse pages by an encoded-byte budget rather than
3846// a fixed count. 4 MiB keeps each frame well under the atomic-write limit even
……@@ -151,7 +159,13 @@ export class OverlayProtocol {
151159 this._schedulers.set(p, sched)
152160 // shared promise so joiners (above) can await the same completion —
153161 // calling sched.promise() twice would overwrite its resolve/reject handlers.
154 sched.shared = sched.promise().finally(() => this._schedulers.delete(p))
162 sched.shared = sched.promise().finally(() => {
163 this._schedulers.delete(p)
164 // F2: drop any half-accumulated paged chunk list for this path across peers, so a
165 // same-key retry (content:<hash> reused after a cancel mid-paging) can't append
166 // fresh pages onto a stale partial list.
167 for (const pr of this._peers.values()) if (pr._chunkHashPages) pr._chunkHashPages.delete(p)
168 })
155169 // honor a cancel/pause that arrived before this scheduler existed.
156170 if (this._cancelPending.delete(p)) { sched.cancel(); return sched.shared }
157171 for (const peer of peers) this.requestContent(peer, contentHash, null)
……@@ -473,6 +487,12 @@ export class OverlayProtocol {
473487 let acc = buffered
474488 if (!acc) { acc = []; peer._chunkHashPages.set(msg.path, acc) }
475489 for (const ch of msg.chunks) acc.push(ch)
490 if (acc.length > MAX_CHUNK_HASHES_ACCUM) {
491 // F2: drop an over-long accumulator (adversarial more:1 flood) instead of
492 // OOMing — the fetch fails and the scheduler retries/fails over to another peer.
493 peer._chunkHashPages.delete(msg.path)
494 return null
495 }
476496 if (msg.more) return null
477497 peer._chunkHashPages.delete(msg.path)
478498 return acc
lib/transfer.js +16 -3
……@@ -451,9 +451,17 @@ export class TransferManager {
451451 const hasherBytes = c.uint64.decode(s)
452452 const hasherState = c.buffer.decode(s)
453453 const bm = c.buffer.decode(s)
454 // F1: never trust a journal we cannot whole-file-verify. Both finalize and the
455 // legacy receive path skip the whole-file hash when contentHash is absent, so a
456 // FOREIGN journal with a matching size + chunk-count (a stale .overlay-partial
457 // for different content the CDC split into the same number of chunks) would be
458 // trusted and finalized as merged old+new bytes, undetected. Require a content
459 // hash to resume from a journal; without one the caller falls back to
460 // _recoverPartialAsync, which re-verifies every chunk by its per-chunk hash.
461 if (!meta.contentHash) return null
454462 if (storedPartial !== partialPath) return null
455463 if (size !== meta.size || total !== meta.chunks.length) return null
456 if (meta.contentHash && ch !== meta.contentHash) return null
464 if (ch !== meta.contentHash) return null
457465 if (meta.contentHash) {
458466 if (stateBytes !== sodium.crypto_generichash_STATEBYTES) return null
459467 if (hasherState.length !== sodium.crypto_generichash_STATEBYTES) return null
……@@ -485,7 +493,10 @@ export class TransferManager {
485493 try { buf = this._encodeJournal(state) } catch { break }
486494 const tmp = state.journalPath + '.tmp'
487495 try {
488 try { await fs.fsync(state.fd) } catch {}
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)
489500 await fs.writeFile(tmp, buf)
490501 if (state.fd == null) break // paused/cancelled mid-flush — don't re-create the journal
491502 await fs.rename(tmp, state.journalPath)
……@@ -501,8 +512,10 @@ export class TransferManager {
501512 if (!state.contentHash || !state.journalPath) return
502513 let buf
503514 try { buf = this._encodeJournal(state) } catch { return }
504 if (state.fd != null) { try { fs.fsyncSync(state.fd) } catch {} }
505515 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)
506519 const tmp = state.journalPath + '.tmp'
507520 fs.writeFileSync(tmp, buf)
508521 fs.renameSync(tmp, state.journalPath)