diff --git a/forge/lib/mirror.js b/forge/lib/mirror.js index 365c2b0..fb4b5f6 100644 --- a/forge/lib/mirror.js +++ b/forge/lib/mirror.js @@ -85,6 +85,10 @@ export function readState(ev) { const txo = tagValue(ev, 'txo'); const commit = tagValue(ev, 'commit'); const mark = chain && txo && SHA40.test(String(commit || '').toLowerCase()) ? { chain, txo, commit: commit.toLowerCase() } : null; + // A state that tries to carry a mark and gets it wrong is not "a state with no + // mark": reading it that way would let a garbled commit tag switch the trail + // check off. Treat it as no state at all, so nothing moves. + if (!mark && (chain !== null || txo !== null || commit !== null)) return null; return { d, head, refs, mark, createdAt: ev.created_at, id: ev.id }; } @@ -124,18 +128,35 @@ export function trailLists(raw, commit) { return doc.states.some((s) => String(s).toLowerCase() === commit); } +/** + * Whether a gitmark trail document belongs to this agent: its pubkeyBase + * (02/03 + x, or a bare x) carries the agent's x-only key. Without this a trail + * copied from any other repository, listing the marked commit, passes trailLists. + */ +export function trailBelongsTo(raw, pubkey) { + let doc; + try { doc = JSON.parse(raw); } catch { return false; } + const base = doc && typeof doc.pubkeyBase === 'string' ? doc.pubkeyBase.toLowerCase() : ''; + const x = /^0[23][0-9a-f]{64}$/.test(base) ? base.slice(2) : (HEX64.test(base) ? base : ''); + return x !== '' && x === String(pubkey).toLowerCase(); +} + /** * The verdict for one branch after the fetch. * @param {object} o * @param {boolean} o.fetched - the commit is in the mirror after the fetch * @param {object|null} o.mark - the state's mark, if any * @param {string|null} o.trail - the trail file at the commit, or null when absent + * @param {string} [o.pubkey] - the followed agent's key; the trail must carry it + * @param {boolean} [o.reachable=true] - the marked commit is the moved-to commit or in its history * @returns {{move: boolean, verdict: string}} */ -export function branchVerdict({ fetched, mark, trail }) { +export function branchVerdict({ fetched, mark, trail, pubkey, reachable = true }) { if (!fetched) return { move: false, verdict: 'the remote does not have the commit the state names' }; if (!mark) return { move: true, verdict: 'fetched; the state carries no mark' }; if (trail === null) return { move: false, verdict: 'the state carries a mark but the commit has no trail file' }; if (!trailLists(trail, mark.commit)) return { move: false, verdict: `the trail at the commit does not list the marked commit ${mark.commit.slice(0, 8)}` }; - return { move: true, verdict: `fetched; the trail lists mark ${mark.commit.slice(0, 8)} on ${mark.chain}` }; + if (!trailBelongsTo(trail, pubkey)) return { move: false, verdict: "the trail at the commit is not the followed agent's (its pubkeyBase differs)" }; + if (!reachable) return { move: false, verdict: `the marked commit ${mark.commit.slice(0, 8)} is not in the history of the commit the state names` }; + return { move: true, verdict: `fetched; the agent's committed trail lists mark ${mark.commit.slice(0, 8)} on ${mark.chain} (the chain itself is not checked here)` }; } diff --git a/forge/plugin.js b/forge/plugin.js index d9069a8..8340cfe 100644 --- a/forge/plugin.js +++ b/forge/plugin.js @@ -1608,6 +1608,16 @@ export async function activate(api) { const rec = mirrorRecord(owner, name) || { pubkey: owner, name, clone: entry.clone, events: 0 }; const dir = repoDirOf(owner, name); const local = repoExists(owner, name) ? await localHeads(dir) : {}; + // A validly signed but older state is a relay replaying history; moving branches + // back to it would be a rollback. A state equal to the newest seen is kept: it is + // the catch-up resend of one the rate limit held back. + if (state && state.createdAt < (rec.newestSeenAt || 0)) { + rec.events += 1; + rec.lastSeen = { id: ev.id, created_at: ev.created_at, at: Date.now(), action: 'skip', reason: 'older than a state already seen (replay)' }; + saveMirrorRecord(owner, name, rec); + return rec; + } + if (state) rec.newestSeenAt = state.createdAt; const plan = planMirror({ state, local, lastSeenAt: rec.lastEvent?.created_at, lastFetchAt: rec.lastFetchAt, now: Date.now(), minIntervalMs: mirror.minIntervalMs }); rec.events += 1; rec.lastSeen = { id: ev.id, created_at: ev.created_at, at: Date.now(), action: plan.action, reason: plan.reason }; @@ -1619,6 +1629,10 @@ export async function activate(api) { } if (!repoExists(owner, name)) await materialize(owner, name, `mirror of ${didShort(owner)}`); rec.lastFetchAt = Date.now(); + // The fetch lands every branch in refs/mirror/* before any verdict. Keep them out of + // what upload-pack advertises, or a branch held back here is still fetchable by name. + const hidden = (await gitText(dir, ['config', '--get-all', 'uploadpack.hideRefs']).catch(() => '')).split('\n'); + if (!hidden.includes('refs/mirror/')) await execFileP('git', ['-C', dir, 'config', '--add', 'uploadpack.hideRefs', 'refs/mirror/'], { env: gitEnv }); try { await execFileP('git', ['-C', dir, 'fetch', '--quiet', '--no-tags', clone, '+refs/heads/*:refs/mirror/*'], { env: { ...gitEnv, GIT_TERMINAL_PROMPT: '0' }, timeout: MIRROR_FETCH_TIMEOUT_MS, maxBuffer: MAX_EXEC_BUFFER }); @@ -1635,7 +1649,11 @@ export async function activate(api) { try { await execFileP('git', ['-C', dir, 'cat-file', '-e', `${sha}^{commit}`], { env: gitEnv }); fetched = true; } catch { fetched = false; } let trail = null; if (fetched && state.mark) { try { trail = await gitText(dir, ['show', `${sha}:${TRAIL_FILE}`]); } catch { trail = null; } } - const v = branchVerdict({ fetched, mark: state.mark, trail }); + let reachable = true; + if (fetched && state.mark) { + reachable = await execFileP('git', ['-C', dir, 'merge-base', '--is-ancestor', state.mark.commit, sha], { env: gitEnv }).then(() => true, () => false); + } + const v = branchVerdict({ fetched, mark: state.mark, trail, pubkey: owner, reachable }); if (v.move) { await execFileP('git', ['-C', dir, 'update-ref', ref, sha], { env: gitEnv }); moved += 1; } branches[ref] = { sha, moved: v.move, verdict: v.verdict }; } diff --git a/forge/test.js b/forge/test.js index e5e1939..abf8810 100644 --- a/forge/test.js +++ b/forge/test.js @@ -2902,7 +2902,7 @@ describe('forge web-edit openEdit demo (anonymous edits)', () => { // pushes new ones, a local upstream repo as the clone URL, and states signed // by an agent key the forge is configured to follow. import { - parseMirrorConfig, verifyEvent, readState, readClones, planMirror, branchVerdict, trailLists, MAX_FOLLOW, + parseMirrorConfig, verifyEvent, readState, readClones, planMirror, branchVerdict, trailLists, trailBelongsTo, MAX_FOLLOW, } from './lib/mirror.js'; function signNostr(sk, { kind, tags, content = '', created_at }) { @@ -2961,8 +2961,12 @@ describe('forge mirror library (pure)', () => { assert.deepStrictEqual(st.refs, { 'refs/heads/main': sha1 }); assert.strictEqual(st.head, 'refs/heads/main'); assert.deepStrictEqual(st.mark, { chain: 'txbt4', txo: 'txo:txbt4:' + 'c'.repeat(64) + ':0', commit: sha1 }); - const noMark = readState(signNostr(sk, { kind: 30618, tags: [['d', 'x'], ['chain', 'txbt4']], created_at: 1 })); + const noMark = readState(signNostr(sk, { kind: 30618, tags: [['d', 'x']], created_at: 1 })); assert.strictEqual(noMark.mark, null); + // a partial or garbled mark must not read as "no mark" (that would switch the trail check off) + for (const extra of [[['chain', 'txbt4']], [['commit', 'zz']], [['chain', 'txbt4'], ['txo', 'txo:x'], ['commit', 'nope']]]) { + assert.strictEqual(readState(signNostr(sk, { kind: 30618, tags: [['d', 'x'], ...extra], created_at: 1 })), null); + } assert.strictEqual(readState(signNostr(sk, { kind: 30618, tags: [], created_at: 1 })), null, 'no d, no state'); }); @@ -2984,15 +2988,29 @@ describe('forge mirror library (pure)', () => { }); it('branchVerdict: unfetched never moves; no mark moves; a mark needs the trail at the commit to list it', () => { - const trail = JSON.stringify({ '@type': 'Blocktrail', profile: 'gitmark', states: [sha1] }); + const key = 'ab'.repeat(32); + const trail = JSON.stringify({ '@type': 'Blocktrail', profile: 'gitmark', pubkeyBase: `02${key}`, states: [sha1] }); assert.strictEqual(branchVerdict({ fetched: false, mark: null, trail: null }).move, false); assert.strictEqual(branchVerdict({ fetched: true, mark: null, trail: null }).move, true); const mark = { chain: 'txbt4', txo: 'txo:…', commit: sha1 }; - assert.strictEqual(branchVerdict({ fetched: true, mark, trail: null }).move, false); - assert.strictEqual(branchVerdict({ fetched: true, mark, trail }).move, true); - assert.strictEqual(branchVerdict({ fetched: true, mark: { ...mark, commit: sha2 }, trail }).move, false); + assert.strictEqual(branchVerdict({ fetched: true, mark, trail: null, pubkey: key }).move, false); + assert.strictEqual(branchVerdict({ fetched: true, mark, trail, pubkey: key }).move, true); + assert.strictEqual(branchVerdict({ fetched: true, mark: { ...mark, commit: sha2 }, trail, pubkey: key }).move, false); assert.strictEqual(trailLists('not json', sha1), false); }); + + it('branchVerdict: the trail must carry the followed key, and the marked commit must be reachable', () => { + const key = 'ab'.repeat(32); + const trail = JSON.stringify({ '@type': 'Blocktrail', profile: 'gitmark', pubkeyBase: `02${key}`, states: [sha1] }); + const mark = { chain: 'txbt4', txo: 'txo:…', commit: sha1 }; + assert.strictEqual(branchVerdict({ fetched: true, mark, trail, pubkey: key }).move, true); + // a trail copied from another agent's repository lists the commit but is not this agent's + assert.strictEqual(branchVerdict({ fetched: true, mark, trail, pubkey: 'cd'.repeat(32) }).move, false); + assert.strictEqual(branchVerdict({ fetched: true, mark, trail, pubkey: key, reachable: false }).move, false); + assert.strictEqual(trailBelongsTo(JSON.stringify({ pubkeyBase: key }), key), true, 'bare x is accepted'); + assert.strictEqual(trailBelongsTo(JSON.stringify({ profile: 'gitmark' }), key), false, 'no key, no match'); + assert.strictEqual(trailBelongsTo('not json', key), false); + }); }); describe('forge mirrors a repo on its agent\'s kind-30618 state (in-process relay, local upstream)', () => { @@ -3123,7 +3141,7 @@ describe('forge mirrors a repo on its agent\'s kind-30618 state (in-process rela push(stateEvent(sk, { 'refs/heads/main': 'f'.repeat(40) }, null, { created_at: 1760000000 })); const rec = await until(async () => { const r = await record(); return r.events === before + 1 ? r : null; }, 'the old state to be counted'); assert.strictEqual(rec.lastSeen.action, 'skip'); - assert.match(rec.lastSeen.reason, /not newer/); + assert.match(rec.lastSeen.reason, /older than a state already seen/); await new Promise((r) => { setTimeout(r, 200); }); assert.strictEqual((await record()).events, before + 1, 'the other key\'s state was never counted'); });