Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions forge/lib/mirror.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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)` };
}
20 changes: 19 additions & 1 deletion forge/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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 });
Expand All @@ -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 };
}
Expand Down
32 changes: 25 additions & 7 deletions forge/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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');
});

Expand All @@ -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)', () => {
Expand Down Expand Up @@ -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');
});
Expand Down