Bring repository in line with the deployed server

Commits the changes already running in production (image rodecomp/server:adminreview-20261028), which were never pushed:

- Repository sync before verification: with pushRemote set, the worker fast-forwards its checkout at idle cohort boundaries, blocks merge/check lanes while a sync is pending or failed, and reloads data/decomp.json, the compile commands and data/toolchain.lock.json from one committed snapshot; the web process adopts only the published snapshot (git.js, verify.js, merge.js, checks.js, maintenance.js, project.js, app.js).
- Symbol corrections: POST /api/admin/symbols/:id accepts decorated and swapDecoratedWith with strict validation, records old/new values, and invalidates cached symbol snapshots; td maintenance symbol --decorated/--swap-decorated-with and MCP td_correct_symbol (cli.js, mcp.js, ops.js, app.js).
- Downloads: /download/ responses are no-store and answer HEAD; self-update revalidates the manifest and fetches the binary with ?sha256= to bypass stale CDN entries (update.js, app.js).
- deploy/rodecomp/compose.yaml: Forgejo SSH enabled on port 22 (SSH_DOMAIN 192.168.1.77), as running.

Tests: repo-sync, symbol-corrections and update suites added; npm test passes (164).
This commit is contained in:
ragnarok-clanker 2026-10-28 16:18:16 +01:00
commit d7c825abc0
16 changed files with 1016 additions and 44 deletions

322
test/repo-sync.test.js Normal file
View file

@ -0,0 +1,322 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { Repo } from '../src/server/git.js';
import { openServer, createApp } from '../src/server/app.js';
import { now } from '../src/server/db.js';
import { fixture as binaryFixture } from './helpers.js';
const quiet = { info() {}, error() {} };
function repositories(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'td-repo-sync-'));
const cleanup = [];
t.after(async () => {
for (const close of cleanup.reverse()) await close();
fs.rmSync(root, { recursive: true, force: true });
});
const remote = path.join(root, 'remote.git');
const producer = path.join(root, 'producer');
const dataDir = path.join(root, 'server');
const local = path.join(dataDir, 'repo');
const git = (dir, ...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: 'pipe' }).trim();
fs.mkdirSync(dataDir);
git(root, 'init', '--bare', '-q', '-b', 'main', remote);
git(root, 'clone', '-q', remote, producer);
const commit = (dir, files, message = 'fixture change') => {
for (const [file, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), typeof content === 'string' ? content : JSON.stringify(content));
}
git(dir, 'add', '-A');
git(dir, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-q', '-m', message);
return git(dir, 'rev-parse', 'HEAD');
};
const base = commit(producer, {
'data/decomp.json': { name: 'before', sourceDirs: ['src'], includeDirs: ['include'], headerGlobs: ['include/**'], obsoleteSetting: true },
'src/base.cpp': 'int base_value;\n',
'.gitattributes': '* text eol=lf\n',
'.gitignore': 'ignored.txt\n',
});
git(producer, 'push', '-q', 'origin', 'HEAD');
git(root, 'clone', '-q', remote, local);
git(local, 'remote', 'rename', 'origin', 'publish');
git(local, 'remote', 'add', 'origin', path.join(root, 'wrong-remote.git'));
const advance = (files) => {
const head = commit(producer, files);
git(producer, 'push', '-q', 'origin', 'HEAD');
return head;
};
return { root, remote, producer, dataDir, local, git, commit, base, advance, cleanup, repo: new Repo(local) };
}
function serverFixture(t, repos) {
fs.writeFileSync(path.join(repos.dataDir, 'server.json'), JSON.stringify({ pushRemote: 'publish', verifyConcurrency: 2, publicUrl: 'http://127.0.0.1', listen: { host: '127.0.0.1', port: 0 } }));
const ctx = openServer(repos.dataDir, { log: quiet });
const exe = path.join(repos.dataDir, 'target.exe');
const pe = binaryFixture().pe;
fs.writeFileSync(exe, pe);
ctx.store.run(`INSERT INTO versions (id, sha256, file, size, is_current, created_at) VALUES ('test', 'test', ?, ?, 1, ?)`, exe, pe.length, now());
const user = Number(ctx.store.run(`INSERT INTO users (name, role, status, created_at) VALUES ('maintainer', 'maintainer', 'active', ?)`, now()).lastInsertRowid);
repos.cleanup.push(() => { ctx.verifier.stop(); ctx.store.db.close(); });
const batch = (kind, base = repos.base, files = { 'forbidden.txt': 'invalid\n' }) => {
const id = Number(ctx.store.run(`INSERT INTO batches (user_id, base_commit, files_json, state, kind, created_at) VALUES (?, ?, ?, 'queued', ?, ?)`, user, base, JSON.stringify(files), kind, now()).lastInsertRowid);
if (kind === 'code') {
ctx.store.run(`INSERT OR IGNORE INTO symbols (id, kind, unit, created_at) VALUES ('fn_1', 'function', 'src/base.cpp', ?)`, now());
const task = Number(ctx.store.run(`INSERT INTO tasks (symbol_id, state, updated_at) VALUES ('fn_1', 'verifying', ?)`, now()).lastInsertRowid);
ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, batch_id, created_at) VALUES (?, ?, ?, ?, 'queued', ?, ?)`, task, user, base, JSON.stringify(files), id, now());
}
return id;
};
return { ctx, batch, user };
}
async function drained(verifier) {
for (let i = 0; i < 100 && verifier.running; i++) await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(verifier.running, 0, 'verification finishes');
}
test('sync fetches pushRemote and fast-forwards its current branch, preserving local-ahead work', (t) => {
const r = repositories(t);
const remoteHead = r.advance({ 'src/remote.cpp': 'int remote_value;\n' });
assert.deepEqual(r.repo.sync('publish'), { remote: 'publish', branch: 'refs/heads/main', before: r.base, head: remoteHead, remoteHead, state: 'fast-forward' });
assert.equal(fs.readFileSync(path.join(r.local, 'src/remote.cpp'), 'utf8'), 'int remote_value;\n');
assert.equal(r.repo.sync('publish').state, 'current');
const ahead = r.commit(r.local, { 'src/local.cpp': 'int local_value;\n' });
assert.equal(r.repo.sync('publish').state, 'ahead');
assert.equal(r.repo.head(), ahead);
assert.equal(r.repo.show('HEAD', 'src/local.cpp'), 'int local_value;\n');
});
test('divergence gives both commits and leaves both histories and worktree intact', (t) => {
const r = repositories(t);
const local = r.commit(r.local, { 'src/local.cpp': 'int local_value;\n' });
const remote = r.advance({ 'src/remote.cpp': 'int remote_value;\n' });
assert.throws(() => r.repo.sync('publish'), (e) => /diverged/.test(e.message) && e.message.includes(local) && e.message.includes(remote) && /maintainer.*reconcile/.test(e.message));
assert.equal(r.repo.head(), local);
assert.equal(r.repo.show('HEAD', 'src/local.cpp'), 'int local_value;\n');
assert.equal(r.repo.show(remote, 'src/remote.cpp'), 'int remote_value;\n');
assert.equal(r.repo.isClean(), true);
});
test('dirty tracked, staged and untracked files are preserved, as are ignored files upstream would track', (t) => {
const r = repositories(t);
r.advance({ 'src/base.cpp': 'int upstream_value;\n', 'new.txt': 'remote\n' });
fs.writeFileSync(path.join(r.local, 'src/base.cpp'), 'int operator_edit;\n');
fs.writeFileSync(path.join(r.local, 'new.txt'), 'local\n');
r.git(r.local, 'add', 'src/base.cpp');
const index = r.git(r.local, 'show', ':src/base.cpp');
assert.throws(() => r.repo.sync('publish'), /dirty server working tree/);
assert.equal(r.repo.head(), r.base);
assert.equal(r.git(r.local, 'show', ':src/base.cpp'), index);
assert.equal(fs.readFileSync(path.join(r.local, 'new.txt'), 'utf8'), 'local\n');
});
test('ignored untracked content is not overwritten by a fast-forward', (t) => {
const r = repositories(t);
fs.writeFileSync(path.join(r.local, 'ignored.txt'), 'operator data\n');
fs.writeFileSync(path.join(r.producer, 'ignored.txt'), 'remote data\n');
r.git(r.producer, 'add', '-f', 'ignored.txt');
r.advance({ 'src/remote.cpp': 'int remote_value;\n' });
assert.throws(() => r.repo.sync('publish'), /overwritten|untracked/i);
assert.equal(r.repo.head(), r.base);
assert.equal(fs.readFileSync(path.join(r.local, 'ignored.txt'), 'utf8'), 'operator data\n');
});
test('failed fetch never reuses FETCH_HEAD or reports success', (t) => {
const r = repositories(t);
r.repo.sync('publish');
r.git(r.local, 'remote', 'set-url', 'publish', path.join(r.root, 'unavailable.git'));
assert.throws(() => r.repo.sync('publish'), /repository sync.*failed.*local HEAD/s);
assert.equal(r.repo.head(), r.base);
});
test('code and maintenance admission waits for every active lane and retries dirty sync safely', async (t) => {
const r = repositories(t);
const { ctx, batch } = serverFixture(t, r);
const remote = r.advance({ 'src/remote.cpp': 'int remote_value;\n' });
const code = batch('code', remote);
const maintenance = batch('maintenance', remote);
for (const lane of [ctx.verifier, ctx.merger, ctx.checks]) {
lane.running = 1;
ctx.verifier.pump();
assert.equal(r.repo.head(), r.base);
assert.equal(ctx.store.get('SELECT state FROM batches WHERE id = ?', code).state, 'queued');
lane.running = 0;
}
fs.writeFileSync(path.join(r.local, 'operator.txt'), 'preserve me\n');
ctx.verifier.pump();
for (const id of [code, maintenance]) {
const row = ctx.store.get('SELECT state, result_json FROM batches WHERE id = ?', id);
assert.equal(row.state, 'queued');
assert.match(JSON.parse(row.result_json).repositorySync.error, /dirty server working tree/);
}
assert.equal(ctx.verifier.repositoryBlocked, true);
assert.equal(fs.readFileSync(path.join(r.local, 'operator.txt'), 'utf8'), 'preserve me\n');
fs.renameSync(path.join(r.local, 'operator.txt'), path.join(r.root, 'preserved.txt'));
ctx.verifier.pump();
await drained(ctx.verifier);
assert.equal(r.repo.head(), remote);
assert.equal(ctx.verifier.repositoryBlocked, false);
for (const id of [code, maintenance]) {
const row = ctx.store.get('SELECT state, result_json FROM batches WHERE id = ?', id);
assert.equal(row.state, 'returned');
assert.match(row.result_json, /static gate/);
assert.equal(JSON.parse(row.result_json).repositorySync, undefined);
}
});
test('sync failure blocks new merge/check work and an operator reconciliation resumes queued work', async (t) => {
const r = repositories(t);
const { ctx, batch, user } = serverFixture(t, r);
const queued = batch('maintenance');
const approved = batch('code');
ctx.store.run(`UPDATE batches SET state = 'open' WHERE id = ?`, approved);
ctx.store.run(`UPDATE attempts SET state = 'approved' WHERE batch_id = ?`, approved);
const checkId = Number(ctx.store.run(`INSERT INTO check_jobs (user_id, files_hash, files_json, state, created_at) VALUES (?, 'test', '{}', 'queued', ?)`, user, now()).lastInsertRowid);
const local = r.commit(r.local, { 'src/local.cpp': 'int local_value;\n' });
const remote = r.advance({ 'src/remote.cpp': 'int remote_value;\n' });
ctx.verifier.pump();
const error = JSON.parse(ctx.store.get('SELECT result_json FROM batches WHERE id = ?', queued).result_json).repositorySync.error;
assert.ok(error.includes(local) && error.includes(remote));
await ctx.merger.pump();
ctx.checks.pump();
assert.equal(ctx.store.get('SELECT state FROM batches WHERE id = ?', approved).state, 'open');
assert.equal(ctx.store.get('SELECT state FROM check_jobs WHERE id = ?', checkId).state, 'queued');
assert.equal(r.repo.head(), local);
// Deliberate operator reconciliation, not an automatic reset/rebase by sync.
r.git(r.producer, 'fetch', '-q', r.local, 'main');
r.git(r.producer, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'merge', '-q', '--no-edit', 'FETCH_HEAD');
r.git(r.producer, 'push', '-q', 'origin', 'HEAD');
ctx.verifier.pump();
await drained(ctx.verifier);
assert.equal(ctx.verifier.repositoryBlocked, false);
assert.equal(ctx.store.get('SELECT state FROM batches WHERE id = ?', queued).state, 'returned');
ctx.checks.pump();
for (let i = 0; i < 100 && ctx.checks.running; i++) await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(ctx.store.get('SELECT state FROM check_jobs WHERE id = ?', checkId).state, 'done');
assert.equal(ctx.repo.show('HEAD', 'src/local.cpp'), 'int local_value;\n');
assert.equal(ctx.repo.show('HEAD', 'src/remote.cpp'), 'int remote_value;\n');
});
test('concurrent batches retain their snapshot until maintenance and code both finish', async (t) => {
const r = repositories(t);
const { ctx, batch } = serverFixture(t, r);
ctx.config.verifyConcurrency = 2;
const releases = [];
const hold = () => new Promise((resolve) => releases.push(resolve));
const code = batch('code');
const maintenance = batch('maintenance');
ctx.verifier.verifyBatch = async (b) => { await hold(); ctx.store.run(`UPDATE batches SET state = 'returned' WHERE id = ?`, b.id); };
ctx.maintenance.run = async (id) => { await hold(); ctx.store.run(`UPDATE batches SET state = 'returned' WHERE id = ?`, id); };
ctx.verifier.pump();
assert.equal(ctx.verifier.running, 2);
assert.equal(ctx.store.get('SELECT state FROM batches WHERE id = ?', code).state, 'verifying');
assert.equal(ctx.store.get('SELECT state FROM batches WHERE id = ?', maintenance).state, 'verifying');
const remote = r.advance({ 'src/cohort.cpp': 'int cohort_value;\n' });
const next = batch('maintenance');
ctx.verifier.pump();
releases.shift()();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(ctx.verifier.running, 1);
assert.equal(r.repo.head(), r.base);
assert.equal(ctx.store.get('SELECT state FROM batches WHERE id = ?', next).state, 'queued');
releases.shift()();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(r.repo.head(), remote);
assert.equal(ctx.store.get('SELECT state FROM batches WHERE id = ?', next).state, 'verifying');
releases.shift()();
await drained(ctx.verifier);
});
test('FF reloads compile-command paths and invalidates old checks before maintenance uses new roots', async (t) => {
const r = repositories(t);
const { ctx, batch } = serverFixture(t, r);
const id = batch('maintenance', r.base, { 'fresh/empty.h': '' });
ctx.store.run(`INSERT INTO batch_checks (batch_id, head, check_json) VALUES (?, ?, '{}')`, id, r.base);
const shared = ctx.project;
const remote = r.advance({
'data/decomp.json': { name: 'after', sourceDirs: ['newsrc'], includeDirs: ['fresh'], headerGlobs: ['fresh/**'], toolchain: 'newtool', compileCommands: 'data/new-commands.json' },
'data/new-commands.json': { shapes: [['/c']], units: {} },
'data/toolchain.lock.json': { newtool: { clSha256: 'new-digest' } },
});
ctx.verifier.pump();
await drained(ctx.verifier);
assert.equal(ctx.store.get('SELECT state FROM batches WHERE id = ?', id).state, 'merged');
assert.equal(ctx.repo.isAncestor(remote), true);
assert.equal(ctx.repo.show('HEAD', 'fresh/empty.h'), '');
assert.equal(ctx.project, shared);
assert.equal(ctx.maintenance.project, shared);
assert.equal(ctx.tasks.project, shared);
assert.equal(ctx.project.name, 'after');
assert.equal(ctx.project.obsoleteSetting, undefined);
assert.deepEqual(ctx.project.compile, { shapes: [['/c']], units: {} });
assert.equal(ctx.config.envDigest, 'new-digest');
assert.equal(ctx.store.get('SELECT COUNT(*) AS n FROM batch_checks').n, 0);
});
test('split web serves only published config and lock snapshots, even during an unpublished FF', async (t) => {
const r = repositories(t);
const { ctx } = serverFixture(t, r);
ctx.verifier.syncRepository();
const web = openServer(r.dataDir, { log: quiet });
web.runsWorker = false;
const server = createApp(web);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
r.cleanup.push(async () => { await new Promise((resolve) => server.close(resolve)); web.store.db.close(); });
const info = async () => {
const response = await fetch(`http://127.0.0.1:${server.address().port}/api/info`);
assert.equal(response.status, 200);
return response.json();
};
r.advance({ 'data/decomp.json': { name: 'published', toolchain: 'newtool' }, 'data/toolchain.lock.json': { newtool: { clSha256: 'published-digest' } } });
r.repo.sync('publish'); // Repo moved, but the worker has not reloaded/published project data yet.
let response = await info();
assert.equal(response.project, 'before');
assert.equal(response.toolchain, 'msvc2010');
assert.deepEqual(response.toolchainLock, {});
fs.writeFileSync(path.join(r.local, 'data/decomp.json'), '{partial worktree write');
ctx.verifier.refreshProject();
ctx.store.setMeta('repository_project_head', r.repo.head());
response = await info();
assert.equal(response.project, 'published');
assert.equal(response.toolchain, 'newtool');
assert.deepEqual(response.toolchainLock, { newtool: { clSha256: 'published-digest' } });
assert.equal(web.config.envDigest, 'published-digest');
});
test('startup refreshes an idle worker even with no queued batches', (t) => {
const r = repositories(t);
const { ctx } = serverFixture(t, r);
const remote = r.advance({ 'data/decomp.json': { name: 'startup-refreshed' } });
ctx.verifier.start();
ctx.verifier.stop();
assert.equal(r.repo.head(), remote);
assert.equal(ctx.project.name, 'startup-refreshed');
assert.equal(ctx.store.getMeta('repository_project_head'), remote);
});
test('missing incoming compile commands block verification without publishing stale project data', async (t) => {
const r = repositories(t);
const { ctx, batch } = serverFixture(t, r);
ctx.verifier.syncRepository();
const id = batch('maintenance');
const remote = r.advance({ 'data/decomp.json': { name: 'broken', compileCommands: 'data/commands.json' } });
ctx.verifier.pump();
assert.equal(r.repo.head(), remote);
assert.equal(ctx.project.name, 'before');
assert.equal(ctx.store.getMeta('repository_project_head'), r.base);
const row = ctx.store.get('SELECT state, result_json FROM batches WHERE id = ?', id);
assert.equal(row.state, 'queued');
assert.match(JSON.parse(row.result_json).repositorySync.error, /missing compile commands: data\/commands\.json/);
const repaired = r.advance({ 'data/commands.json': { shapes: [['/c']], units: {} } });
ctx.verifier.pump();
await drained(ctx.verifier);
assert.equal(ctx.project.name, 'broken');
assert.equal(ctx.store.getMeta('repository_project_head'), repaired);
assert.equal(ctx.verifier.repositoryBlocked, false);
assert.equal(ctx.store.get('SELECT state FROM batches WHERE id = ?', id).state, 'returned');
});

View file

@ -0,0 +1,246 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Store, hashToken, now } from '../src/server/db.js';
import { Accounts } from '../src/server/accounts.js';
import { createApp, DEFAULT_CONFIG } from '../src/server/app.js';
import { Workspace } from '../src/client/workspace.js';
import { correctSymbol, refreshSymbols } from '../src/client/ops.js';
async function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'td-symbol-corrections-'));
const store = new Store(path.join(dir, 'td.sqlite'));
const accounts = new Accounts(store);
for (const [id, current] of [['current', 1], ['previous', 0]]) {
store.run('INSERT INTO versions (id, sha256, file, size, is_current, created_at) VALUES (?, ?, ?, 1, ?, ?)', id, 'fixture', 'unused.exe', current, now());
}
const symbols = ['?First@@YAXXZ', '?Second@@YAXXZ', null].map((decorated, i) => {
const { symbol } = store.ensureSymbol({ kind: 'function', versionId: 'current', addr: 0x401000 + i * 0x100, size: 16, name: `Function${i}`, unit: 'src/functions.cpp' });
store.run('UPDATE symbols SET decorated = ? WHERE id = ?', decorated, symbol.id);
store.run('INSERT INTO symbol_addresses (symbol_id, version_id, addr, size) VALUES (?, ?, ?, ?)', symbol.id, 'previous', 0x501000 + i * 0x100, 12);
store.run("INSERT INTO func_status (symbol_id, file, status, score, commit_hash, updated_at, confirmed, code_hash) VALUES (?, 'src/functions.cpp', 'equivalent', 0.9, 'original-commit', 123, 1, 'original-code')", symbol.id);
store.run("INSERT INTO tasks (symbol_id, state, updated_at) VALUES (?, 'done', 123)", symbol.id);
return symbol.id;
});
const user = (role) => {
const id = Number(store.run("INSERT INTO users (name, role, status, created_at) VALUES (?, ?, 'active', ?)", role, role, now()).lastInsertRowid);
const token = accounts.createApiToken(id, 'test').token;
const session = `tds_${role}`;
store.run('INSERT INTO sessions (user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?)', id, hashToken(session), now(), now() + 60_000);
return { id, token, cookie: `td_session=${session}` };
};
const maintainer = user('maintainer');
const contributor = user('contributor');
const config = { ...DEFAULT_CONFIG, dataDir: dir };
const server = createApp({ store, accounts, config, project: {}, runsWorker: false });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
store.db.close();
fs.rmSync(dir, { recursive: true, force: true });
});
const base = `http://127.0.0.1:${server.address().port}`;
const post = async (id, body, auth = { token: maintainer.token }) => {
const res = await fetch(`${base}/api/admin/symbols/${id}`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(auth.token && { authorization: `Bearer ${auth.token}` }), ...(auth.cookie && { cookie: auth.cookie }), ...(auth.csrf && { 'x-td-csrf': '1' }) },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
};
const snapshot = async (version = 'current', etag) => {
const res = await fetch(`${base}/api/symbols?version=${version}`, { headers: etag ? { 'if-none-match': etag } : {} });
if (res.status === 304) return { status: 304 };
const body = await res.json();
return { status: res.status, etag: res.headers.get('etag'), symbols: new Map(body.symbols.map((row) => [row[0], Object.fromEntries(body.columns.map((c, i) => [c, row[i]]))])) };
};
const state = () => Object.fromEntries(['symbols', 'symbol_addresses', 'func_status', 'tasks'].map((table) => [table, store.all(`SELECT * FROM ${table} ORDER BY rowid`).map((row) => ({ ...row }))]));
const events = () => store.all("SELECT user_id, type, data FROM events WHERE type = 'symbol_corrected' ORDER BY id").map((event) => ({ ...event, data: JSON.parse(event.data) }));
return { store, symbols, maintainer, contributor, post, snapshot, state, events, base, dir };
}
test('decorated corrections require maintainer authorization and session CSRF', async (t) => {
const f = await fixture(t);
const before = f.state();
const body = { decorated: '?Corrected@@YAXXZ', reason: 'Original export and disassembly identify this function.' };
for (const [auth, status] of [[{}, 401], [{ token: 'td_invalid' }, 401], [{ token: f.contributor.token }, 403], [{ cookie: f.maintainer.cookie }, 403]]) {
assert.equal((await f.post(f.symbols[0], body, auth)).status, status);
}
assert.deepEqual(f.state(), before);
assert.deepEqual(f.events(), []);
assert.equal((await f.post(f.symbols[0], body, { cookie: f.maintainer.cookie, csrf: true })).status, 200);
});
test('decorated corrections reject malformed values without coercion, truncation or partial writes', async (t) => {
const f = await fixture(t);
const before = f.state();
for (const decorated of [null, 0, true, {}, [], '', ' ', ' ?Name@@YAXXZ', '?Name@@YAXXZ ', 'bad\nname', 'bad\n', 'bad\r', 'bad\u0000name', 'bad\u007fname', 'caf\u00e9', 'x'.repeat(2049)]) {
assert.equal((await f.post(f.symbols[0], { name: 'Must not persist', size: 99, decorated, reason: 'Evidence' })).status, 400, JSON.stringify(decorated));
}
for (const body of [null, [], 'wrong', { decorated: '?New@@YAXXZ' }, { decorated: '?New@@YAXXZ', reason: {} }, { decorated: '?New@@YAXXZ', reason: ' ' }, { decorated: '?New@@YAXXZ', reason: 'x'.repeat(501) }, { decorated: '?New@@YAXXZ', reason: 'Evidence', addr: 123 }]) {
assert.equal((await f.post(f.symbols[0], body)).status, 400, JSON.stringify(body));
}
assert.deepEqual(f.state(), before);
assert.deepEqual(f.events(), []);
});
test('duplicate decorated names roll back every requested correction', async (t) => {
const f = await fixture(t);
const before = f.state();
const result = await f.post(f.symbols[0], { decorated: '?Second@@YAXXZ', name: 'Must not persist', size: 99, reason: 'Evidence' });
assert.equal(result.status, 409);
assert.match(result.body.error, new RegExp(f.symbols[1]));
assert.deepEqual(f.state(), before);
assert.deepEqual(f.events(), []);
});
test('audited decorated corrections preserve identity and confirmation and invalidate every version snapshot', async (t) => {
const f = await fixture(t);
const before = f.state();
const snapshots = await Promise.all(['current', 'previous'].map((v) => f.snapshot(v)));
const reason = 'Original export and disassembly identify this function.';
const result = await f.post(f.symbols[0], { decorated: '?Corrected@@YAXXZ', reason });
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.deepEqual(result.body, { id: f.symbols[0], decorated: '?Corrected@@YAXXZ' });
const expected = structuredClone(before);
expected.symbols[0].decorated = '?Corrected@@YAXXZ';
assert.deepEqual(f.state(), expected);
assert.deepEqual(f.events(), [{ user_id: f.maintainer.id, type: 'symbol_corrected', data: { id: f.symbols[0], decorated: '?Corrected@@YAXXZ', old: { decorated: '?First@@YAXXZ' }, new: { decorated: '?Corrected@@YAXXZ' }, reason } }]);
for (const [i, version] of ['current', 'previous'].entries()) {
const snapshot = await f.snapshot(version, snapshots[i].etag);
assert.equal(snapshot.status, 200);
assert.notEqual(snapshot.etag, snapshots[i].etag);
assert.equal(snapshot.symbols.get(f.symbols[0]).decorated, '?Corrected@@YAXXZ');
assert.equal((await f.snapshot(version, snapshot.etag)).status, 304);
}
});
test('atomic decorated swaps audit both directions and preserve addresses and function state', async (t) => {
const f = await fixture(t);
const before = f.state();
const cached = await f.snapshot();
const reason = 'Original call targets show the two recovered names were reversed.';
const result = await f.post(f.symbols[0], { swapDecoratedWith: f.symbols[1], reason });
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.deepEqual(result.body, { id: f.symbols[0], decorated: '?Second@@YAXXZ', swapped: { id: f.symbols[1], decorated: '?First@@YAXXZ' } });
const expected = structuredClone(before);
[expected.symbols[0].decorated, expected.symbols[1].decorated] = [expected.symbols[1].decorated, expected.symbols[0].decorated];
assert.deepEqual(f.state(), expected);
const events = f.events();
assert.deepEqual(events.map((e) => [e.user_id, e.data.id, e.data.old.decorated, e.data.new.decorated, e.data.swapDecoratedWith, e.data.reason]), [
[f.maintainer.id, f.symbols[0], '?First@@YAXXZ', '?Second@@YAXXZ', f.symbols[1], reason],
[f.maintainer.id, f.symbols[1], '?Second@@YAXXZ', '?First@@YAXXZ', f.symbols[0], reason],
]);
const fresh = await f.snapshot('current', cached.etag);
assert.equal(fresh.status, 200);
assert.deepEqual(f.symbols.slice(0, 2).map((id) => fresh.symbols.get(id).decorated), ['?Second@@YAXXZ', '?First@@YAXXZ']);
});
test('unsafe swaps reject missing, ambiguous or conflicting mappings transactionally', async (t) => {
const f = await fixture(t);
const before = f.state();
for (const [body, status] of [
[{ swapDecoratedWith: f.symbols[0] }, 400],
[{ swapDecoratedWith: f.symbols[2] }, 409],
[{ swapDecoratedWith: 'fn_99999' }, 404],
[{ swapDecoratedWith: null }, 400],
[{ swapDecoratedWith: 123 }, 400],
[{ swapDecoratedWith: f.symbols[1], decorated: '?Other@@YAXXZ' }, 400],
[{ swapDecoratedWith: f.symbols[1], name: 'Other' }, 400],
[{ swapDecoratedWith: f.symbols[1], size: 99 }, 400],
[{ swapDecoratedWith: f.symbols[1], retire: true }, 400],
]) {
assert.equal((await f.post(f.symbols[0], { ...body, reason: 'Evidence' })).status, status, JSON.stringify(body));
}
assert.deepEqual(f.state(), before);
f.store.run('UPDATE symbols SET decorated = ? WHERE id = ?', '?First@@YAXXZ', f.symbols[2]);
const ambiguous = f.state();
assert.equal((await f.post(f.symbols[0], { swapDecoratedWith: f.symbols[1], reason: 'Evidence' })).status, 409);
assert.deepEqual(f.state(), ambiguous);
assert.deepEqual(f.events(), []);
});
test('decorated swaps reject function-vtable exchanges without audit or state changes', async (t) => {
const f = await fixture(t);
const { symbol: vtable } = f.store.ensureSymbol({ kind: 'vtable', versionId: 'current', addr: 0x405000, size: 16, name: 'Widget vtable' });
f.store.run('UPDATE symbols SET decorated = ? WHERE id = ?', '??_7Widget@@6B@', vtable.id);
const before = f.state();
const revision = f.store.getMeta('symbolCorrectionsRevision');
const snapshot = await f.snapshot();
const result = await f.post(f.symbols[0], { swapDecoratedWith: vtable.id, reason: 'Mistaken cross-kind correction' });
assert.equal(result.status, 409);
assert.deepEqual(f.state(), before);
assert.deepEqual(f.events(), []);
assert.equal(f.store.getMeta('symbolCorrectionsRevision'), revision);
assert.equal((await f.snapshot('current', snapshot.etag)).status, 304);
});
test('competing corrections cannot bind one decorated name to two IDs', async (t) => {
const f = await fixture(t);
const results = await Promise.all(f.symbols.slice(0, 2).map((id) => f.post(id, { decorated: '?Contested@@YAXXZ', reason: 'Evidence' })));
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]);
const winner = results.find((r) => r.status === 200).body.id;
assert.deepEqual(f.store.all('SELECT id FROM symbols WHERE decorated = ?', '?Contested@@YAXXZ').map((r) => r.id), [winner]);
assert.deepEqual(f.events().map((e) => e.data.id), [winner]);
});
test('an audit insert failure rolls back both halves of a decorated swap', async (t) => {
const f = await fixture(t);
const before = f.state();
f.store.db.exec(`CREATE TRIGGER fail_second_audit BEFORE INSERT ON events WHEN NEW.type = 'symbol_corrected' AND json_extract(NEW.data, '$.id') = '${f.symbols[1]}' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`);
assert.equal((await f.post(f.symbols[0], { swapDecoratedWith: f.symbols[1], reason: 'Evidence' })).status, 500);
assert.deepEqual(f.state(), before);
assert.deepEqual(f.events(), []);
});
test('decorated correction cache invalidation reaches another web process database connection', async (t) => {
const f = await fixture(t);
const otherStore = new Store(path.join(f.dir, 'td.sqlite'));
const otherServer = createApp({ store: otherStore, accounts: new Accounts(otherStore), config: { ...DEFAULT_CONFIG, dataDir: f.dir }, project: {}, runsWorker: false });
await new Promise((resolve) => otherServer.listen(0, '127.0.0.1', resolve));
try {
const url = `http://127.0.0.1:${otherServer.address().port}/api/symbols`;
const cached = await fetch(url);
const etag = cached.headers.get('etag');
await cached.json();
assert.equal((await f.post(f.symbols[0], { decorated: '?CrossProcess@@YAXXZ', reason: 'Evidence' })).status, 200);
const changed = await fetch(url, { headers: { 'if-none-match': etag } });
assert.equal(changed.status, 200);
const snapshot = await changed.json();
assert.equal(snapshot.symbols.find((row) => row[0] === f.symbols[0])[snapshot.columns.indexOf('decorated')], '?CrossProcess@@YAXXZ');
} finally {
await new Promise((resolve) => otherServer.close(resolve));
otherStore.db.close();
}
});
test('decorated names retain C linkage and maximum-length spellings exactly', async (t) => {
const f = await fixture(t);
for (const decorated of ['_Function@4', '@Function@4', 'Function', '?'.repeat(2048)]) {
const result = await f.post(f.symbols[2], { decorated, reason: 'Original symbol spelling' });
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(f.store.get('SELECT decorated FROM symbols WHERE id = ?', f.symbols[2]).decorated, decorated);
}
});
test('client corrections and swaps replace cached and persisted decorated-name lookups', async (t) => {
const f = await fixture(t);
const ws = new Workspace(path.join(f.dir, 'workspace'));
ws.config = { server: f.base, token: f.maintainer.token };
ws.save();
await refreshSymbols(ws);
assert.equal(ws.findSymbol('?First@@YAXXZ').id, f.symbols[0]);
await correctSymbol(ws, f.symbols[0], { decorated: '?ClientCorrected@@YAXXZ', reason: 'Evidence' });
assert.equal(ws.findSymbol('?First@@YAXXZ'), null);
assert.equal(ws.nameIndex.get('?ClientCorrected@@YAXXZ'), f.symbols[0]);
assert.equal(new Workspace(ws.dir).findSymbol('?ClientCorrected@@YAXXZ').id, f.symbols[0]);
await correctSymbol(ws, f.symbols[0], { swapDecoratedWith: f.symbols[1], reason: 'Evidence' });
assert.equal(ws.nameIndex.get('?ClientCorrected@@YAXXZ'), f.symbols[1]);
assert.equal(ws.nameIndex.get('?Second@@YAXXZ'), f.symbols[0]);
const persisted = fs.readFileSync(ws.symbolsFile, 'utf8');
await assert.rejects(correctSymbol(ws, f.symbols[0], { decorated: '?ClientCorrected@@YAXXZ', reason: 'Evidence' }), (error) => error.status === 409);
assert.equal(fs.readFileSync(ws.symbolsFile, 'utf8'), persisted);
assert.equal(new Workspace(ws.dir).findSymbol('?ClientCorrected@@YAXXZ').id, f.symbols[1]);
});

122
test/update.test.js Normal file
View file

@ -0,0 +1,122 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { gzipSync } from 'node:zlib';
import { createApp, DEFAULT_CONFIG } from '../src/server/app.js';
const exec = promisify(execFile);
const ASSET = process.platform === 'win32' ? 'td-windows-x64.exe' : 'td-linux-x64';
const hash = (data) => crypto.createHash('sha256').update(data).digest('hex');
const installed = Buffer.from('installed td build');
const published = Buffer.from('newly published td build');
async function listen(t, server, dir) {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
});
return `http://127.0.0.1:${server.address().port}`;
}
async function runUpdater(dir, server) {
// Isolate the SEA gate and executable/home paths, not the updater's HTTP, hashing or writes.
const script = `
import { createRequire } from 'node:module';
import path from 'node:path';
const [dir, server, updater] = process.argv.slice(1);
const require = createRequire(import.meta.url);
require('node:sea').isSea = () => true;
require('node:os').homedir = () => dir;
process.execPath = path.join(dir, 'td');
const { maybeUpdate } = await import(updater);
const logs = [];
const result = await maybeUpdate({ server, force: true, log: (message) => logs.push(message) });
console.log(JSON.stringify({ result, logs }));
`;
const { stdout } = await exec(process.execPath, ['--input-type=module', '-e', script, dir, server, new URL('../src/client/update.js', import.meta.url).href], {
env: { ...process.env, TD_NO_UPDATE: '0' },
timeout: 15_000,
});
return JSON.parse(stdout);
}
async function cachedDownloads(t, corrupt = false) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'td-update-'));
const exe = path.join(dir, 'td');
fs.writeFileSync(exe, installed);
const requests = [];
const server = http.createServer((req, res) => {
requests.push(req.url);
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/download/SHA256SUMS') {
// A cache serves the old manifest unless the client requests revalidation.
const revalidate = /(?:no-cache|no-store|max-age=0)/.test(req.headers['cache-control'] ?? '');
return res.end(`${hash(revalidate ? published : installed)} ${ASSET}\n`);
}
if (url.pathname === `/download/${ASSET}`) {
// The mutable URL is stale; only a new content-keyed URL reaches the new build.
const keyed = url.searchParams.get('sha256') === hash(published);
return res.end(keyed && !corrupt ? published : installed);
}
res.writeHead(404);
res.end();
});
return { dir, exe, requests, base: await listen(t, server, dir) };
}
test('self-update revalidates the manifest and bypasses a stale unversioned binary', async (t) => {
const f = await cachedDownloads(t);
const { result } = await runUpdater(f.dir, f.base);
assert.equal(typeof result, 'string', 'the downloaded update was installed');
assert.deepEqual(fs.readFileSync(f.exe), published);
assert.deepEqual(f.requests, ['/download/SHA256SUMS', `/download/${ASSET}?sha256=${hash(published)}`]);
assert.equal(fs.existsSync(f.exe + '.new'), false);
});
test('self-update rejects mismatched bytes even at the expected SHA256 URL', async (t) => {
const f = await cachedDownloads(t, true);
const { result } = await runUpdater(f.dir, f.base);
assert.deepEqual(f.requests, ['/download/SHA256SUMS', `/download/${ASSET}?sha256=${hash(published)}`]);
assert.equal(result, null);
assert.deepEqual(fs.readFileSync(f.exe), installed, 'checksum failure must preserve the installed executable');
assert.equal(fs.existsSync(f.exe + '.new'), false);
assert.equal(fs.existsSync(f.exe + '.old'), false);
});
test('mutable downloads are not stored by caches for GET or HEAD, including gzip and missing assets', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'td-download-'));
const downloads = path.join(dir, 'downloads');
fs.mkdirSync(downloads);
fs.writeFileSync(path.join(downloads, ASSET), published);
const zipped = gzipSync(published);
fs.writeFileSync(path.join(downloads, ASSET + '.gz'), zipped);
const manifest = `${hash(published)} ${ASSET}\n`;
fs.writeFileSync(path.join(downloads, 'SHA256SUMS'), manifest);
const server = createApp({ config: { ...DEFAULT_CONFIG, dataDir: dir }, project: {}, runsWorker: false });
const base = await listen(t, server, dir);
for (const method of ['GET', 'HEAD']) {
for (const [asset, encoding, body, length] of [
[ASSET, 'gzip', published, zipped.length],
[`${ASSET}?sha256=${hash(published)}`, 'identity', published, published.length],
['SHA256SUMS', 'identity', Buffer.from(manifest), Buffer.byteLength(manifest)],
]) {
const res = await fetch(`${base}/download/${asset}`, { method, headers: { 'accept-encoding': encoding } });
assert.equal(res.status, 200);
assert.equal(res.headers.get('cache-control'), 'no-store');
assert.equal(res.headers.get('content-encoding'), encoding === 'gzip' ? 'gzip' : null);
assert.equal(Number(res.headers.get('content-length')), length);
assert.deepEqual(Buffer.from(await res.arrayBuffer()), method === 'HEAD' ? Buffer.alloc(0) : body);
}
const missing = await fetch(`${base}/download/missing`, { method });
assert.equal(missing.status, 404);
assert.equal(missing.headers.get('cache-control'), 'no-store');
await missing.arrayBuffer();
}
});