Files

337 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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`);
}
});