| … | … | @@ -33,6 +33,14 @@ const CAP_ADAPTIVE_CHUNKS = 0x02 |
| 33 | 33 | // ≤8 MiB send-batching. |
| 34 | 34 | const MAX_CHUNKS_PER_MSG = 100000 |
| 35 | 35 | |
| 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. | |
| 42 | const MAX_CHUNK_HASHES_ACCUM = 16 * 1024 * 1024 | |
| 43 | ||
| 36 | 44 | // Tree entries are variable length (name is a var-string), unlike the fixed-width |
| 37 | 45 | // chunkHashes entries, so treeResponse pages by an encoded-byte budget rather than |
| 38 | 46 | // a fixed count. 4 MiB keeps each frame well under the atomic-write limit even |
| … | … | @@ -151,7 +159,13 @@ export class OverlayProtocol { |
| 151 | 159 | this._schedulers.set(p, sched) |
| 152 | 160 | // shared promise so joiners (above) can await the same completion — |
| 153 | 161 | // 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 | }) | |
| 155 | 169 | // honor a cancel/pause that arrived before this scheduler existed. |
| 156 | 170 | if (this._cancelPending.delete(p)) { sched.cancel(); return sched.shared } |
| 157 | 171 | for (const peer of peers) this.requestContent(peer, contentHash, null) |
| … | … | @@ -473,6 +487,12 @@ export class OverlayProtocol { |
| 473 | 487 | let acc = buffered |
| 474 | 488 | if (!acc) { acc = []; peer._chunkHashPages.set(msg.path, acc) } |
| 475 | 489 | 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 | } | |
| 476 | 496 | if (msg.more) return null |
| 477 | 497 | peer._chunkHashPages.delete(msg.path) |
| 478 | 498 | return acc |
| … | … | @@ -451,9 +451,17 @@ export class TransferManager { |
| 451 | 451 | const hasherBytes = c.uint64.decode(s) |
| 452 | 452 | const hasherState = c.buffer.decode(s) |
| 453 | 453 | 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 | |
| 454 | 462 | if (storedPartial !== partialPath) return null |
| 455 | 463 | 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 | |
| 457 | 465 | if (meta.contentHash) { |
| 458 | 466 | if (stateBytes !== sodium.crypto_generichash_STATEBYTES) return null |
| 459 | 467 | if (hasherState.length !== sodium.crypto_generichash_STATEBYTES) return null |
| … | … | @@ -485,7 +493,10 @@ export class TransferManager { |
| 485 | 493 | try { buf = this._encodeJournal(state) } catch { break } |
| 486 | 494 | const tmp = state.journalPath + '.tmp' |
| 487 | 495 | 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) | |
| 489 | 500 | await fs.writeFile(tmp, buf) |
| 490 | 501 | if (state.fd == null) break // paused/cancelled mid-flush — don't re-create the journal |
| 491 | 502 | await fs.rename(tmp, state.journalPath) |
| … | … | @@ -501,8 +512,10 @@ export class TransferManager { |
| 501 | 512 | if (!state.contentHash || !state.journalPath) return |
| 502 | 513 | let buf |
| 503 | 514 | try { buf = this._encodeJournal(state) } catch { return } |
| 504 | if (state.fd != null) { try { fs.fsyncSync(state.fd) } catch {} } | |
| 505 | 515 | 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) | |
| 506 | 519 | const tmp = state.journalPath + '.tmp' |
| 507 | 520 | fs.writeFileSync(tmp, buf) |
| 508 | 521 | fs.renameSync(tmp, state.journalPath) |