fb5-patch-pkg: the update package's boot step and install check (server/update.js) — before the server starts (runMain: npm start, launch.mjs, the Windows service's run-server.cmd, NSSM, systemd; launch.mjs also before setup) an extracted UPDATE.json is checked: malformed → logged and skipped; the install verified against MANIFEST.json, and a missing or different file the server or browser runs stops the start with the bilingual "download the full package" message (nothing deleted, UPDATE.json kept); else each removed file still holding a base's bytes is deleted (never a changed file, a player's pack, a symlink, a per-machine name or a path outside the install) and UPDATE.json becomes .update-applied.json; doctor gets a MANIFEST.json row; test/update-boot.test.js (10)

This commit is contained in:
sganggs committed 2026-10-07 11:23:42 +08:00
1 parent 0b306baa49
commit d6e40c7235
6 files changed
+710 -7

No files matched your search

+336
View File
@@ -0,0 +1,336 @@
// test/update-boot.test.js — the update package on the player's machine (server/update.js, docs/DEPLOY.md §1.5): the
// paths UPDATE.json may name, the boot step that finishes an update (deletes, renames, refuses a mismatched install,
// skips a malformed file), and the MANIFEST.json check `npm run doctor` shows. Temporary folders only.
// Run: node --test test/update-boot.test.js (the packaging side: test/update-package.test.js)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
APPLIED_FILE, MANIFEST_FILE, UPDATE_FILE, affectsRuntime, applyPendingUpdate, checkInstall, deleteRemoved, digestFile,
inManifest, isSetupArt, parseManifest, parseUpdate, pathProblem, removalProblem, verifyFiles,
} from '../server/update.js';
const sha = (body) => crypto.createHash('sha256').update(body).digest('hex');
const dg = (body) => ({ size: Buffer.byteLength(body), sha256: sha(body) });
const tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `sp-update-${tag}-`));
const put = (dir, rel, body = `// ${rel}\n`) => {
fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
};
const has = (dir, rel) => fs.existsSync(path.join(dir, rel));
const read = (dir, rel) => fs.readFileSync(path.join(dir, rel), 'utf8');
const quiet = () => {
const lines = [];
return { lines, log: { log: (m) => lines.push(['log', m]), warn: (m) => lines.push(['warn', m]), error: (m) => lines.push(['error', m]) } };
};
/** MANIFEST.json / UPDATE.json as tools/package-update.mjs writes them (the same keys; its line layout is not needed here). */
const manifestText = ({ app, files }) => JSON.stringify({ format: 1, app, files: Object.fromEntries(files) });
const updateText = ({ app, from, files, removed }) => JSON.stringify({ format: 1, app, from, count: files.size,
bytes: [...files.values()].reduce((n, d) => n + d.size, 0), files: Object.fromEntries(files), removed });
// ---------------------------------------------------------------------------------------------------------------------
// The paths an update may name
test('refusal rules: traversal, absolute and drive paths, backslashes, per-machine folders and the update files are never removed', () => {
for (const p of ['../x', 'a/../../x', 'server/../../etc/passwd', 'a/./b', 'a//b', 'a/', '', '..', '.']) assert.ok(pathProblem(p), `traversal: ${JSON.stringify(p)}`);
for (const p of ['/etc/passwd', 'C:/Windows/x', 'C:\\Windows\\x', 'c:x', '\\\\server\\share\\x', 'server\\index.js', 'a\0b', 'a/b.', 'a/b ']) {
assert.ok(pathProblem(p), `absolute / not plain: ${JSON.stringify(p)}`);
}
for (const p of [null, undefined, 3, {}]) assert.ok(pathProblem(p) && removalProblem(p), String(p));
for (const p of ['.git/config', '.cache/assets-report.json', 'logs/server.log', '.venv-extract/bin/python', 'tools/local-extract/.venv/x',
'data/.env', '.env.local', 'scripts/service.env.cmd', 'node_modules/.cache/x', 'handoff/HANDOFF.md', '.claude/settings.json',
MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]) {
assert.ok(removalProblem(p), `never removed: ${p}`);
assert.equal(pathProblem(p), null, `${p} may still be listed (a manifest path is only read)`);
}
for (const p of ['../x', '/etc/passwd', 'C:/x']) assert.ok(removalProblem(p), p);
for (const p of ['server/old.js', 'packs/qab/pack.json', 'node_modules/three/build/three.cjs', 'public/assets/ui/b c.png', 'data/i18n/zh-TW.json',
'docs/research/07-assets.json', 'README.md']) {
assert.equal(removalProblem(p), null, p);
}
// a dependency's own .env.example can be verified (MANIFEST.json) but an update never deletes it
const m = parseManifest(JSON.stringify({ format: 1, app: '0.2.1', files: { 'node_modules/x/.env.example': dg('x') } }));
assert.deepEqual([...m.files.keys()], ['node_modules/x/.env.example']);
});
const H = sha('x');
const goodUpdate = (patch = {}) => JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 1, bytes: 1,
files: { 'server/a.js': { size: 1, sha256: H } }, removed: [{ path: 'server/old.js', sha256: [H] }], ...patch });
test('UPDATE.json is read strictly: one bad entry and the whole file is refused', () => {
const u = parseUpdate(goodUpdate());
assert.equal(u.app, '0.2.1');
assert.deepEqual(u.removed, [{ path: 'server/old.js', sha256: [H] }]);
assert.deepEqual([...u.files.keys()], ['server/a.js']);
const bad = {
'not JSON': '{"format": 1,',
'not an object': '[]',
'another format': goodUpdate({ format: 2 }),
'no version': goodUpdate({ app: 'latest' }),
'no base': goodUpdate({ from: [] }),
'no count': goodUpdate({ count: -1 }),
'files not an object': goodUpdate({ files: [] }),
'a file without a hash': goodUpdate({ files: { 'server/a.js': { size: 1 } } }),
'a file outside the install': goodUpdate({ files: { '../a.js': { size: 1, sha256: H } } }),
'removed not a list': goodUpdate({ removed: {} }),
'traversal': goodUpdate({ removed: [{ path: '../../outside.txt', sha256: [H] }] }),
'absolute': goodUpdate({ removed: [{ path: '/etc/hosts', sha256: [H] }] }),
'a user folder': goodUpdate({ removed: [{ path: 'logs/server.log', sha256: [H] }] }),
'twice': goodUpdate({ removed: [{ path: 'server/old.js', sha256: [H] }, { path: 'server/old.js', sha256: [H] }] }),
'shipped and removed': goodUpdate({ removed: [{ path: 'server/a.js', sha256: [H] }] }),
'no hash': goodUpdate({ removed: [{ path: 'server/old.js', sha256: [] }] }),
'a bad hash': goodUpdate({ removed: [{ path: 'server/old.js', sha256: ['abc'] }] }),
'a bare path': goodUpdate({ removed: ['server/old.js'] }),
};
for (const [what, text] of Object.entries(bad)) assert.throws(() => parseUpdate(text), undefined, what);
});
// ---------------------------------------------------------------------------------------------------------------------
// The boot step
/** An install of the new version: `files` on disk, MANIFEST.json over the non-art ones, UPDATE.json as given. */
function newInstall(onDisk, { app = '0.2.1', from = ['0.2.0'], shipped = [], removed = [], manifest = null } = {}) {
const dir = tmp('inst');
for (const [rel, body] of Object.entries(onDisk)) put(dir, rel, body);
const listed = new Map();
for (const rel of manifest ?? Object.keys(onDisk).filter(inManifest)) listed.set(rel, digestFile(path.join(dir, rel)));
fs.writeFileSync(path.join(dir, MANIFEST_FILE), manifestText({ app, files: listed }));
const ship = new Map(shipped.map((rel) => [rel, digestFile(path.join(dir, rel))]));
fs.writeFileSync(path.join(dir, UPDATE_FILE), updateText({ app, from, files: ship, removed }));
return dir;
}
test('boot apply: deletes the removed files that hold a base\'s bytes, keeps changed ones and players\' packs, renames UPDATE.json, then does nothing', () => {
const dir = newInstall({
'server/index.js': 'new', 'shared/constants.js': "APP_VERSION='0.2.1'", 'README.md': 'readme',
// left over from 0.2.0
'server/old.js': 'old', 'server/lib/only/gone.js': 'gone', 'data/old.json': 'edited here', 'packs/qab/pack.json': 'shipped pack',
'packs/mine/pack.json': 'a player\'s own pack',
}, {
shipped: ['server/index.js', 'shared/constants.js'],
manifest: ['server/index.js', 'shared/constants.js', 'README.md'],
removed: [
{ path: 'server/old.js', sha256: [sha('old')] },
{ path: 'server/lib/only/gone.js', sha256: [sha('gone')] },
{ path: 'data/old.json', sha256: [sha('as shipped')] },
{ path: 'packs/qab/pack.json', sha256: [sha('shipped pack')] },
{ path: 'packs/mine/pack.json', sha256: [sha('the 0.2.0 pack of that name')] },
{ path: 'server/never-here.js', sha256: [sha('x')] },
],
});
try {
const { lines, log } = quiet();
const r = applyPendingUpdate(dir, { log });
assert.equal(r.state, 'applied', JSON.stringify(lines));
assert.deepEqual(r.deleted.sort(), ['packs/qab/pack.json', 'server/lib/only/gone.js', 'server/old.js']);
assert.deepEqual(r.kept.sort(), ['data/old.json', 'packs/mine/pack.json'], 'other bytes than any base shipped: left alone');
assert.equal(r.absent, 1);
assert.ok(!has(dir, 'server/old.js') && !has(dir, 'server/lib'), 'the folders it leaves empty go too');
assert.ok(has(dir, 'server/index.js') && has(dir, 'packs/mine/pack.json') && has(dir, 'data/old.json'));
assert.ok(!has(dir, 'packs/qab') && has(dir, 'packs'), 'an emptied pack folder goes, the packs folder with another pack stays');
assert.ok(!has(dir, UPDATE_FILE) && has(dir, APPLIED_FILE), 'UPDATE.json → .update-applied.json');
assert.equal(parseUpdate(read(dir, APPLIED_FILE)).app, '0.2.1');
assert.match(lines.map((l) => l[1]).join('\n'), /已更新到 v0\.2\.1:3 个文件校验通过,删除 3 个旧文件 · updated to v0\.2\.1/);
assert.ok(lines.some(([k, m]) => k === 'warn' && /保留了 2 个/.test(m)), 'the kept files are named');
// idempotent: nothing pending, nothing changes
const before = fs.readdirSync(dir, { recursive: true }).sort();
assert.equal(applyPendingUpdate(dir, { log }).state, 'none');
assert.deepEqual(fs.readdirSync(dir, { recursive: true }).sort(), before);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('boot apply: a malformed UPDATE.json is logged and skipped — nothing deleted, the file kept, never a throw', () => {
const outside = tmp('outside');
put(outside, 'victim.txt', 'keep me');
const dir = newInstall({ 'server/index.js': 'new', 'server/old.js': 'old' });
try {
const cases = [
'{"format": 1, "app": "0.2.1"', // truncated
JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 0, bytes: 0, files: {},
removed: [{ path: 'server/old.js', sha256: [sha('old')] }, { path: `../${path.basename(outside)}/victim.txt`, sha256: [sha('keep me')] }] }),
JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 0, bytes: 0, files: {}, removed: [{ path: path.join(outside, 'victim.txt'), sha256: [sha('keep me')] }] }),
JSON.stringify({ format: 99 }),
'null',
];
for (const text of cases) {
fs.writeFileSync(path.join(dir, UPDATE_FILE), text);
const { lines, log } = quiet();
const r = applyPendingUpdate(dir, { log });
assert.equal(r.state, 'malformed', text);
assert.ok(has(dir, UPDATE_FILE) && !has(dir, APPLIED_FILE), 'left in place');
assert.ok(has(dir, 'server/old.js'), 'one bad entry: not even the good ones are deleted');
assert.equal(read(outside, 'victim.txt'), 'keep me');
assert.match(lines[0][1], /UPDATE\.json 格式不对,已跳过,服务器按现有文件启动/);
}
fs.rmSync(path.join(dir, UPDATE_FILE));
fs.mkdirSync(path.join(dir, UPDATE_FILE)); // unreadable (a folder)
assert.equal(applyPendingUpdate(dir, quiet()).state, 'malformed');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
test('boot apply: a file the server runs that does not match MANIFEST.json stops the start; nothing is deleted, UPDATE.json stays', () => {
const dir = newInstall({ 'server/index.js': 'new', 'data/chess.json': '{}', 'server/old.js': 'old' }, {
manifest: ['server/index.js', 'data/chess.json'],
removed: [{ path: 'server/old.js', sha256: [sha('old')] }],
});
try {
put(dir, 'data/chess.json', '{"old": 1}'); // the base's copy: the update was extracted over another version
fs.rmSync(path.join(dir, 'server/index.js'));
const { lines, log } = quiet();
const r = applyPendingUpdate(dir, { log });
assert.equal(r.state, 'failed');
assert.deepEqual(r.fatal, ['data/chess.json', 'server/index.js']);
assert.ok(has(dir, 'server/old.js') && has(dir, UPDATE_FILE) && !has(dir, APPLIED_FILE));
const text = lines.filter(([k]) => k === 'error').map((l) => l[1]).join('\n');
assert.match(text, /这个更新包只能覆盖在 v0\.2\.0 的整合包安装上(检测到 2 个文件与 v0\.2\.1 不一致或缺失,例:data\/chess\.json、server\/index\.js)。服务器没有启动:请下载 v0\.2\.1 的完整包/);
assert.match(text, /This update package only applies over a v0\.2\.0 install \(2 files differ from v0\.2\.1 or are missing/);
// fixed (the right files extracted): the next start applies it
put(dir, 'data/chess.json', '{}');
put(dir, 'server/index.js', 'new');
assert.equal(applyPendingUpdate(dir, quiet()).state, 'applied');
assert.ok(!has(dir, 'server/old.js'));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('boot apply: a missing or foreign MANIFEST.json stops the start; docs-only differences and missing art are warnings', () => {
const a = newInstall({ 'server/index.js': 'new' });
const b = newInstall({ 'server/index.js': 'new' });
const c = newInstall({ 'server/index.js': 'new', 'README.md': 'readme', 'scripts/start.sh': 'sh', 'node_modules/ws/README.md': 'r', 'public/assets/x.png': 'x' },
{ shipped: ['public/assets/x.png'] });
try {
fs.rmSync(path.join(a, MANIFEST_FILE));
let q = quiet();
assert.equal(applyPendingUpdate(a, { log: q.log }).state, 'failed');
assert.match(q.lines.map((l) => l[1]).join('\n'), /没有完整解压(MANIFEST\.json is missing)/);
fs.writeFileSync(path.join(b, MANIFEST_FILE), manifestText({ app: '0.2.2', files: new Map() }));
q = quiet();
assert.equal(applyPendingUpdate(b, { log: q.log }).state, 'failed');
assert.match(q.lines.map((l) => l[1]).join('\n'), /MANIFEST\.json belongs to v0\.2\.2/);
put(c, 'README.md', 'edited');
put(c, 'scripts/start.sh', 'edited');
put(c, 'node_modules/ws/README.md', 'edited');
fs.rmSync(path.join(c, 'public/assets/x.png'));
q = quiet();
const r = applyPendingUpdate(c, { log: q.log });
assert.equal(r.state, 'applied');
assert.deepEqual(r.notes, ['README.md', 'node_modules/ws/README.md', 'scripts/start.sh']);
assert.deepEqual(r.art, ['public/assets/x.png']);
const warned = q.lines.filter(([k]) => k === 'warn').map((l) => l[1]).join('\n');
assert.match(warned, /3 个说明 \/ 脚本文件与 v0\.2\.1 不一致(不影响运行/);
assert.match(warned, /1 个素材文件没有完整解压/);
} finally {
for (const d of [a, b, c]) fs.rmSync(d, { recursive: true, force: true });
}
});
test('boot apply: a local-art manifest the update brought to an install without that art is named (a lite install)', () => {
const local = JSON.stringify({ groups: { g: { x: { path: '/assets/local/g/x.webp' }, y: { path: '/assets/local/g/y.webp' } } } });
const dir = newInstall({ 'server/index.js': 'new', 'data/local-assets.json': local, 'public/assets/local/g/y.webp': 'y' },
{ shipped: ['data/local-assets.json', 'public/assets/local/g/y.webp'] });
try {
const q = quiet();
const r = applyPendingUpdate(dir, { log: q.log });
assert.equal(r.state, 'applied');
assert.deepEqual(r.localGaps, ['public/assets/local/g/x.webp']);
assert.match(q.lines.map((l) => l[1]).join('\n'), /本地客户端素材不完整:data\/local-assets\.json 列出的 1 个文件不在这台电脑上/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('deleting removed files: never through a symlink, never a folder, never a file of the new version that differs only in case', () => {
const dir = tmp('del');
const outside = tmp('outside');
try {
put(outside, 'x.js', 'old');
put(dir, 'server/dir.js/inner.js', 'old');
put(dir, 'public/assets/X.png', 'png');
let links = true;
try {
fs.symlinkSync(path.join(outside, 'x.js'), path.join(dir, 'server', 'link.js'));
fs.symlinkSync(outside, path.join(dir, 'linked'), 'dir');
} catch { links = false; } // Windows without the symlink privilege
const r = deleteRemoved(dir, [
{ path: 'server/link.js', sha256: [sha('old')] },
{ path: 'linked/x.js', sha256: [sha('old')] },
{ path: 'server/dir.js', sha256: [sha('old')] },
{ path: 'public/assets/X.png', sha256: [sha('png')] },
{ path: '../escape.js', sha256: [sha('old')] },
], new Set(['public/assets/x.png']));
assert.deepEqual(r.deleted, []);
assert.equal(read(outside, 'x.js'), 'old', 'the symlink targets are untouched');
assert.ok(has(dir, 'public/assets/X.png') && has(dir, 'server/dir.js/inner.js'));
const why = r.skipped.join('\n');
if (links) {
assert.match(why, /server\/link\.js \(not a regular file\)/);
assert.match(why, /linked\/x\.js \(outside the install\)/);
}
assert.match(why, /server\/dir\.js \(not a regular file\)/);
assert.match(why, /public\/assets\/X\.png \(a file of the new version\)/);
assert.match(why, /\.\.\/escape\.js/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
// ---------------------------------------------------------------------------------------------------------------------
// The manifest check
test('manifest verification: missing files, other sizes, same size other bytes, a folder in place of a file; doctor\'s states', () => {
const dir = tmp('verify');
try {
put(dir, 'a.js', 'aaaa');
put(dir, 'b.js', 'bbbb');
put(dir, 'c.js', 'cccc');
fs.mkdirSync(path.join(dir, 'd.js'));
const v = verifyFiles(dir, new Map([['a.js', dg('aaaa')], ['b.js', dg('bbbbb')], ['c.js', dg('CCCC')], ['d.js', dg('d')], ['e.js', dg('e')]]));
assert.deepEqual(v, { checked: 5, missing: ['d.js', 'e.js'], mismatched: ['b.js', 'c.js'] });
assert.deepEqual(checkInstall(dir), { state: 'none', pending: false }, 'no MANIFEST.json: a source checkout');
fs.writeFileSync(path.join(dir, MANIFEST_FILE), '{');
assert.equal(checkInstall(dir).state, 'broken');
const listed = new Map([['a.js', dg('aaaa')], ['README.md', dg('readme')]]);
put(dir, 'README.md', 'readme');
fs.writeFileSync(path.join(dir, MANIFEST_FILE), manifestText({ app: '0.2.1', files: listed }));
assert.deepEqual(checkInstall(dir), { state: 'ok', pending: false, app: '0.2.1', checked: 2, missing: [], mismatched: [], runtime: [], other: [] });
put(dir, 'README.md', 'edited');
put(dir, 'a.js', 'AAAA');
put(dir, UPDATE_FILE, '{}');
const c = checkInstall(dir);
assert.equal(c.state, 'mismatch');
assert.deepEqual([c.runtime, c.other, c.pending], [['a.js'], ['README.md'], true]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('what MANIFEST.json lists and what stops an update', () => {
for (const f of ['public/assets/char/a.png', 'public/fonts/fonts.css', 'data/assets.json', 'data/local-assets.json']) {
assert.ok(isSetupArt(f) && !inManifest(f), f);
}
for (const f of [MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]) assert.ok(!inManifest(f), f);
for (const f of ['server/index.js', 'data/chess.json', 'node_modules/ws/index.js', 'public/vendor/pixi.min.js', 'README.md', 'packs/index.json']) {
assert.ok(inManifest(f), f);
}
for (const f of ['server/index.js', 'shared/constants.js', 'data/chess.json', 'public/js/main.js', 'public/vendor/pixi.min.js', 'packs/index.json',
'node_modules/ws/package.json', 'node_modules/ws/lib/websocket.js', 'node_modules/x/license.js', 'docs/research/03-operators.json', 'package.json']) {
assert.ok(affectsRuntime(f), `${f} stops an update`);
}
for (const f of ['README.md', 'CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'docs/DEPLOY.md', 'node_modules/ws/README.md', 'node_modules/three/LICENSE',
'node_modules/preact/src/index.d.ts', 'node_modules/x/dist/x.js.map', 'scripts/start-windows.bat', 'tools/setup.mjs',
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'package-lock.json', 'node_modules/.package-lock.json']) {
assert.ok(!affectsRuntime(f), `${f} only warns`);
}
});