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).
122 lines
5.7 KiB
JavaScript
122 lines
5.7 KiB
JavaScript
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();
|
|
}
|
|
});
|