| … | … | @@ -0,0 +1,143 @@ |
| 1 | // ensureLocal must VERIFY a local hit against the converged pointer, not just find one. |
| 2 | // |
| 3 | // The pointer design converges { $oid, size } in the shared autobase and keeps the bytes as |
| 4 | // content-addressed overlay files. ensureLocal returned any local file it found WITHOUT comparing it |
| 5 | // to the requested oid, so content-addressing was used to FETCH but never to VERIFY. A node holding a |
| 6 | // SUPERSEDED copy of a record resolved it happily, never re-fetched, and signed it as truth. |
| 7 | // |
| 8 | // Live on wj62 (2026-07-27): two co-owners, identical 139 converged pointers, both resolving all 139, |
| 9 | // yet different signed repos — differing on exactly one record, app.bsky.actor.profile/self, with two |
| 10 | // different avatar blob refs. The published profile therefore flipped avatar on every signer change. |
| 11 | // |
| 12 | // The repair must not destroy what we hold until the replacement is verified, so it fetches to a |
| 13 | // sibling temp path, re-asserts the hash, then renames into place. |
| 14 | // |
| 15 | // Run: brittle-node test/ensure-local-verify.test.js |
| 16 | import test from 'brittle' |
| 17 | import fs from 'fs' |
| 18 | import os from 'os' |
| 19 | import path from 'path' |
| 20 | import crypto from 'hypercore-crypto' |
| 21 | import { OverlayRepoStore } from '../lib/overlay-repo-store.js' |
| 22 | |
| 23 | const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'els-')) |
| 24 | const POST = 'app.bsky.feed.post' |
| 25 | const PROFILE = 'app.bsky.actor.profile' |
| 26 | |
| 27 | // A content-addressed transport. `net` is the set of bytes any peer can serve. |
| 28 | function mkOverlay (net, opts = {}) { |
| 29 | return { |
| 30 | registered: [], |
| 31 | async registerFile (overlayPath, diskPath, o = {}) { |
| 32 | const bytes = fs.readFileSync(diskPath) |
| 33 | const oid = crypto.data(bytes).toString('hex') |
| 34 | net.set(oid, bytes) |
| 35 | this.registered.push({ overlayPath, diskPath, oid }) |
| 36 | return { contentHash: oid } |
| 37 | }, |
| 38 | async fetchFile (oid, o = {}) { |
| 39 | if (opts.offline) return null |
| 40 | const bytes = net.get(oid) |
| 41 | if (!bytes) return null |
| 42 | fs.writeFileSync(o.destPath, opts.corrupt ? Buffer.concat([bytes, Buffer.from('x')]) : bytes) |
| 43 | if (o.reSeed !== false) this.registered.push({ reSeeded: o.destPath, oid }) |
| 44 | return { destPath: o.destPath, local: false, size: bytes.length } |
| 45 | }, |
| 46 | async hashFile (p) { |
| 47 | let bytes |
| 48 | try { bytes = fs.readFileSync(p) } catch { return null } |
| 49 | return { contentHash: crypto.data(bytes).toString('hex'), size: bytes.length } |
| 50 | } |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | const MINE = { $type: PROFILE, displayName: 'wj62', avatar: { ref: { $link: 'bafkreiAAA' }, size: 6533 } } |
| 55 | const THEIRS = { $type: PROFILE, displayName: 'wj62', avatar: { ref: { $link: 'bafkreiBBB' }, size: 5962 } } |
| 56 | |
| 57 | // Author a record as the other peer would: seeds `net` and returns the converged pointer oid. |
| 58 | async function authorElsewhere (net, collection, rkey, record) { |
| 59 | const s = await new OverlayRepoStore(tmp(), { overlay: mkOverlay(net) }).ready() |
| 60 | const { oid } = await s.put(collection, rkey, record) |
| 61 | return oid |
| 62 | } |
| 63 | |
| 64 | test('a local hit that does NOT match the pointer is replaced with the authoritative bytes', async (t) => { |
| 65 | const net = new Map() |
| 66 | const root = tmp() |
| 67 | const ov = mkOverlay(net) |
| 68 | const store = await new OverlayRepoStore(root, { overlay: ov, did: 'did:plc:x' }).ready() |
| 69 | |
| 70 | const { oid: mineOid } = await store.put(PROFILE, 'self', MINE) // what we hold |
| 71 | const theirsOid = await authorElsewhere(net, PROFILE, 'self', THEIRS) // what converged |
| 72 | t.not(mineOid, theirsOid, 'precondition: the pointer names different bytes than we hold') |
| 73 | |
| 74 | const got = await store.ensureLocal(PROFILE, 'self', theirsOid) |
| 75 | t.alike(got, THEIRS, 'resolves to the record the group agreed on, not our superseded copy') |
| 76 | |
| 77 | const after = await store.localOid(PROFILE, 'self') |
| 78 | t.is(after.oid, theirsOid, 'the file on disk now hashes to the converged pointer') |
| 79 | t.alike(await store.get(PROFILE, 'self'), THEIRS, 'and reads back as the authoritative record') |
| 80 | t.absent(fs.existsSync(path.join(root, 'records', PROFILE, 'self.dagcbor.incoming-' + theirsOid.slice(0, 12))), 'no temp file left behind') |
| 81 | t.ok(ov.registered.some((r) => r.diskPath && r.diskPath.endsWith('self.dagcbor') && r.oid === theirsOid), 're-registered the FINAL path so this node now serves the correct bytes') |
| 82 | }) |
| 83 | |
| 84 | test('a matching local hit is returned without any fetch (the common path is untouched)', async (t) => { |
| 85 | const net = new Map() |
| 86 | const ov = mkOverlay(net) |
| 87 | const store = await new OverlayRepoStore(tmp(), { overlay: ov, did: 'did:plc:x' }).ready() |
| 88 | const { oid } = await store.put(POST, '3kaaa', { $type: POST, text: 'hi', createdAt: '2026-07-27T00:00:00.000Z' }) |
| 89 | |
| 90 | const before = ov.registered.length |
| 91 | const got = await store.ensureLocal(POST, '3kaaa', oid) |
| 92 | t.alike(got, { $type: POST, text: 'hi', createdAt: '2026-07-27T00:00:00.000Z' }, 'returns the local record') |
| 93 | t.is(ov.registered.length, before, 'and did not fetch or re-register anything') |
| 94 | }) |
| 95 | |
| 96 | test('with no oid to check against, a local hit is returned unchanged (legacy / no-overlay)', async (t) => { |
| 97 | const net = new Map() |
| 98 | const store = await new OverlayRepoStore(tmp(), { overlay: mkOverlay(net), did: 'did:plc:x' }).ready() |
| 99 | await store.put(POST, '3kbbb', { $type: POST, text: 'legacy', createdAt: '2026-07-27T00:00:00.000Z' }) |
| 100 | const got = await store.ensureLocal(POST, '3kbbb', null) |
| 101 | t.alike(got, { $type: POST, text: 'legacy', createdAt: '2026-07-27T00:00:00.000Z' }, 'unchanged behaviour when there is nothing to verify against') |
| 102 | }) |
| 103 | |
| 104 | test('if the authoritative bytes cannot be fetched, the STALE file is preserved and the read is unresolved', async (t) => { |
| 105 | const net = new Map() |
| 106 | const root = tmp() |
| 107 | const store = await new OverlayRepoStore(root, { overlay: mkOverlay(net, { offline: true }), did: 'did:plc:x' }).ready() |
| 108 | |
| 109 | const { oid: mineOid } = await store.put(PROFILE, 'self', MINE) |
| 110 | const theirsOid = await authorElsewhere(new Map(), PROFILE, 'self', THEIRS) // nobody reachable serves it |
| 111 | |
| 112 | const got = await store.ensureLocal(PROFILE, 'self', theirsOid) |
| 113 | t.is(got, null, 'returns null — a node that cannot prove its bytes match the pointer must not sign them') |
| 114 | |
| 115 | const still = await store.localOid(PROFILE, 'self') |
| 116 | t.is(still.oid, mineOid, 'the superseded file is PRESERVED, not destroyed, on a failed repair') |
| 117 | t.absent(fs.existsSync(path.join(root, 'records', PROFILE, 'self.dagcbor.incoming-' + theirsOid.slice(0, 12))), 'and no temp file is left behind') |
| 118 | }) |
| 119 | |
| 120 | test('bytes that arrive not matching the requested oid are rejected and the local file is kept', async (t) => { |
| 121 | const net = new Map() |
| 122 | const root = tmp() |
| 123 | const store = await new OverlayRepoStore(root, { overlay: mkOverlay(net, { corrupt: true }), did: 'did:plc:x' }).ready() |
| 124 | |
| 125 | const { oid: mineOid } = await store.put(PROFILE, 'self', MINE) |
| 126 | const theirsOid = await authorElsewhere(net, PROFILE, 'self', THEIRS) |
| 127 | |
| 128 | const got = await store.ensureLocal(PROFILE, 'self', theirsOid) |
| 129 | t.is(got, null, 'mismatched bytes are refused') |
| 130 | const still = await store.localOid(PROFILE, 'self') |
| 131 | t.is(still.oid, mineOid, 'the existing file is untouched — persistent state is never replaced by unverified bytes') |
| 132 | }) |
| 133 | |
| 134 | test('a pointer with no local bytes still fetches normally (the unchanged missing-bytes path)', async (t) => { |
| 135 | const net = new Map() |
| 136 | const root = tmp() |
| 137 | const store = await new OverlayRepoStore(root, { overlay: mkOverlay(net), did: 'did:plc:x' }).ready() |
| 138 | const oid = await authorElsewhere(net, POST, '3kccc', { $type: POST, text: 'pulled', createdAt: '2026-07-27T00:00:00.000Z' }) |
| 139 | |
| 140 | const got = await store.ensureLocal(POST, '3kccc', oid) |
| 141 | t.alike(got, { $type: POST, text: 'pulled', createdAt: '2026-07-27T00:00:00.000Z' }, 'fetched into place') |
| 142 | t.is((await store.localOid(POST, '3kccc')).oid, oid, 'and hashes to the pointer') |
| 143 | }) |