feedback6: merge fb5-patch-pkg — from 0.2.1 on every release also ships an update zip (only the files changed since the earlier 0.2.x releases; the server checks the install against MANIFEST.json and deletes the files the new version dropped on its first start; manual download, no auto-update — the owner's decision of 2026-10-07); README English summary: both the update-zip sentence and the Languages line kept

This commit is contained in:
sganggs committed 2026-10-07 16:47:27 +08:00
commit ee43fb7c14
13 files changed
+1588 -67

No files matched your search

+2 -1
View File
@@ -247,7 +247,8 @@ test('a --no-install build of the temporary checkout zips exactly the plan in on
assert.deepEqual(fs.readdirSync(out), [path.basename(zip)], 'the stage is removed after zipping');
if (hasUnzip) {
const entries = spawnSync('unzip', ['-Z1', zip], { encoding: 'utf8' }).stdout.split('\n').filter((l) => l && !l.endsWith('/'));
assert.deepEqual(entries.sort(), [...SHIPPED_TRACKED, ...ART].map((f) => `${FOLDER}/${f}`).sort());
// and MANIFEST.json, written after npm ci (server/update.js; test/update-package.test.js checks its content)
assert.deepEqual(entries.sort(), [...SHIPPED_TRACKED, ...ART, 'MANIFEST.json'].map((f) => `${FOLDER}/${f}`).sort());
}
const again = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty']);
assert.equal(again.status, 1, 'an existing zip is not replaced without --force');
+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`);
}
});
+342
View File
@@ -0,0 +1,342 @@
// test/update-package.test.js — the update package's packaging side (tools/package.mjs --update, tools/package-update.mjs,
// docs/DEPLOY.md §7): the comparison with the bases, reading a base (full zip or folder) and refusing what cannot be one,
// the zip reader, MANIFEST.json / UPDATE.json in the personal-info scan, MANIFEST.json in every zip and a whole update
// round trip on a small temporary checkout (--no-install), finished by the boot step. No network, no npm install.
// Run: node --test test/update-package.test.js (the player's side: test/update-boot.test.js)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
import { APPLIED_FILE, MANIFEST_FILE, UPDATE_FILE, applyPendingUpdate, checkInstall, parseUpdate, removalProblem } from '../server/update.js';
import { compareVersions, diffBases, readBase, readZip } from '../tools/package-update.mjs';
import { FOLDER, readBases, scanFiles, stageProblems } from '../tools/package.mjs';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const TOOL = path.join(ROOT, 'tools', 'package.mjs');
const ENV = { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'plantedname' };
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]) } };
};
const files = (obj) => new Map(Object.entries(obj).map(([rel, body]) => [rel, dg(body)]));
// ---------------------------------------------------------------------------------------------------------------------
// The comparison
test('diff: a file ships when it is new or differs from the base; removed = what the base shipped and the new one does not', () => {
const next = files({ 'server/a.js': 'A2', 'server/b.js': 'B', 'server/new.js': 'N', 'public/assets/x.png': 'X' });
const base = { version: '0.2.0', files: files({ 'server/a.js': 'A1', 'server/b.js': 'B', 'server/gone.js': 'G', 'public/assets/x.png': 'X' }) };
const d = diffBases(next, [base]);
assert.deepEqual(d.ship, ['server/a.js', 'server/new.js'], 'changed + added; unchanged files and art stay out');
assert.deepEqual(d.removed, [{ path: 'server/gone.js', sha256: [sha('G')] }]);
assert.deepEqual(d.perBase, [{ version: '0.2.0', added: 1, changed: 1, unchanged: 2, removed: 1 }]);
assert.deepEqual(d.caseOnly, []);
// same size, other bytes (a version string 0.2.0 → 0.2.1) still ships
const same = diffBases(files({ 'shared/constants.js': "v='0.2.1'" }), [{ version: '0.2.0', files: files({ 'shared/constants.js': "v='0.2.0'" }) }]);
assert.deepEqual(same.ship, ['shared/constants.js']);
});
test('diff with two bases: cumulative — one update works over each; a file changed in the later base and reverted ships', () => {
const v020 = { version: '0.2.0', files: files({ a: 'a0', b: 'b0', c: 'c0', d: 'd0', g: 'g0', 'P/x.png': 'p' }) };
const v021 = { version: '0.2.1', files: files({ a: 'a1', b: 'b0', c: 'c1', e: 'e1', g: 'g1', 'P/x.png': 'p' }) };
// 0.2.2: a reverted to 0.2.0's bytes, b unchanged everywhere, c new bytes, d re-added after 0.2.1 dropped it,
// e (only 0.2.1 had it) and g (both had it, with other bytes) dropped, P/X.png renamed by case only
const next = files({ a: 'a0', b: 'b0', c: 'c2', d: 'd0', 'P/X.png': 'p' });
const d = diffBases(next, [v020, v021]);
assert.deepEqual(d.ship, ['P/X.png', 'a', 'c', 'd'], 'a differs from 0.2.1, d is missing in 0.2.1, c differs from both');
assert.ok(!d.ship.includes('b'), 'the same in every base: not shipped');
assert.deepEqual(d.removed, [
{ path: 'e', sha256: [sha('e1')] },
{ path: 'g', sha256: [sha('g0'), sha('g1')].sort() },
], 'each base\'s bytes, so the copy of either install is recognised');
assert.deepEqual(d.caseOnly, ['P/x.png'], 'one file on Windows / macOS: never deleted');
assert.deepEqual(d.perBase.map((b) => [b.version, b.added, b.changed, b.unchanged, b.removed]), [
['0.2.0', 1, 1, 3, 2], // P/X.png is new to both bases (compared case-sensitively); removed: g and P/x.png
['0.2.1', 2, 2, 1, 3], // added: d and P/X.png; changed: a and c; removed: e, g and P/x.png
]);
});
test('diff: a name no update deletes (node_modules/x/.env.example) is left out of removed and listed as left', () => {
const base = { version: '0.2.0', files: files({ 'node_modules/x/index.js': 'x', 'node_modules/x/.env.example': 'E=1', 'server/a.js': 'a' }) };
const d = diffBases(files({ 'server/a.js': 'a' }), [base], { removable: (rel) => !removalProblem(rel) });
assert.deepEqual(d.removed.map((r) => r.path), ['node_modules/x/index.js']);
assert.deepEqual(d.left, ['node_modules/x/.env.example']);
assert.deepEqual(diffBases(files({}), [base]).left, [], 'everything is removable by default');
});
test('versions compare numerically, a pre-release before its release', () => {
assert.equal(compareVersions('0.2.0', '0.2.1'), -1);
assert.equal(compareVersions('0.10.0', '0.9.9'), 1);
assert.equal(compareVersions('0.2.1-test', '0.2.1'), -1);
assert.equal(compareVersions('0.2.0', '0.2.1-test'), -1);
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
assert.throws(() => compareVersions('x', '1.0.0'));
});
// ---------------------------------------------------------------------------------------------------------------------
// The checks MANIFEST.json and UPDATE.json pass
test('MANIFEST.json and UPDATE.json pass the personal-info scan: paths are scanned, sha256 values (a hex-only name could match) are not', () => {
const dir = tmp('scan');
try {
const hex = `${'0'.repeat(30)}beef${'0'.repeat(30)}`;
fs.writeFileSync(path.join(dir, MANIFEST_FILE), `{"format":1,"app":"9.9.9","files":{\n"a.js":{"size":1,"sha256":"${hex}"}\n}}\n`);
assert.deepEqual(scanFiles(dir, [MANIFEST_FILE], { names: ['beef'] }), [`${MANIFEST_FILE} (account name)`], 'the plain scan would refuse it');
assert.deepEqual(scanFiles(dir, [MANIFEST_FILE], { names: ['beef'], strip: /[0-9a-f]{64}/g }), []);
assert.deepEqual(stageProblems(dir, new Set([MANIFEST_FILE]), { env: { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'beef' } }).problems, []);
fs.writeFileSync(path.join(dir, UPDATE_FILE), `{"removed":[{"path":"${['', 'home', 'someone'].join('/')}/x"}]}`);
assert.deepEqual(stageProblems(dir, new Set([MANIFEST_FILE, UPDATE_FILE]), { env: ENV }).problems, [`personal info: ${UPDATE_FILE} (home-directory path)`]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// ---------------------------------------------------------------------------------------------------------------------
// Bases and a whole round trip on a temporary checkout (tools/package.mjs, --no-install)
const hasZipTool = !spawnSync('zip', ['-v'], { encoding: 'utf8' }).error
|| /bsdtar/.test(spawnSync('tar', ['--version'], { encoding: 'utf8' }).stdout || '');
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
function fakeCheckout(version) {
const dir = tmp('co');
const scripts = { start: 'node server/index.js', setup: 'node tools/setup.mjs', doctor: 'node tools/doctor.mjs', launch: 'node scripts/launch.mjs',
postinstall: 'node tools/vendor.mjs', vendor: 'node tools/vendor.mjs', assets: 'node tools/vendor.mjs && node tools/fetch-assets.mjs' };
put(dir, 'package.json', JSON.stringify({ name: 'sp-test', version, main: 'server/index.js', scripts, dependencies: {} }));
put(dir, 'package-lock.json', JSON.stringify({ name: 'sp-test', version, lockfileVersion: 3, packages: { '': { name: 'sp-test', version } } }));
put(dir, 'server/index.js', "import './sim/rng.js';\n");
put(dir, 'server/sim/rng.js', 'export const rng = 1;\n');
put(dir, 'server/old.js', 'export const old = 1;\n');
put(dir, 'shared/constants.js', `export const APP_VERSION = '${version}';\n`);
put(dir, 'public/index.html', '<!doctype html>\n');
put(dir, 'data/chess.json', '{}\n');
put(dir, 'data/assets.json', JSON.stringify({ chars: { a: { avatar: '/assets/char/a.png' } }, fonts: { css: '/fonts/fonts.css' } }));
for (const f of ['tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs', 'scripts/launch.mjs', 'tools/golden.mjs']) put(dir, f);
put(dir, 'README.md', '# readme\n');
put(dir, '.gitignore', 'public/assets/\npublic/fonts/\ndata/local-assets.json\n');
put(dir, 'public/assets/char/a.png', PNG);
put(dir, 'public/fonts/fonts.css', '@font-face{}\n');
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
assert.equal(git('init', '-q').status, 0);
assert.equal(git('add', '-A').status, 0);
return { dir, git };
}
function bump({ dir }, version) {
for (const f of ['package.json', 'package-lock.json']) put(dir, f, read(dir, f).replaceAll(/"version":"[^"]+"/g, `"version":"${version}"`));
put(dir, 'shared/constants.js', `export const APP_VERSION = '${version}';\n`);
}
const runTool = (args, env = ENV) => spawnSync(process.execPath, [TOOL, ...args], { encoding: 'utf8', env: { ...process.env, ...env }, maxBuffer: 16 * 1024 * 1024 });
/** Extract a release zip into `dir` (over what is there), as a player would: readZip writes every file. */
function extract(zipPath, dir) {
readZip(zipPath, (name, data) => {
const to = path.join(dir, ...name.split('/'));
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.writeFileSync(to, data);
});
}
test('a whole update: base zip v9.9.8 → --update v9.9.9 ships only what changed, the boot step finishes it over the extracted base', { skip: !hasZipTool && 'no zip / tar' }, () => {
const co = fakeCheckout('9.9.8');
const out = tmp('out');
const inst = tmp('player');
try {
let r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty']);
assert.equal(r.status, 0, r.stdout + r.stderr);
const baseZip = path.join(out, `${FOLDER}-v9.9.8.zip`);
// the next version: a code change, a new module, a dropped one, other art, the version
bump(co, '9.9.9');
put(co.dir, 'server/sim/rng.js', 'export const rng = 2;\n');
put(co.dir, 'server/new.js', 'export const n = 1;\n');
put(co.dir, 'public/assets/char/a.png', Buffer.concat([PNG, Buffer.from('v2')]));
fs.rmSync(path.join(co.dir, 'server/old.js'));
assert.equal(co.git('add', '-A').status, 0);
r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--update', '--from', baseZip]);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.match(r.stdout, /^base: v9\.9\.8 · \d+ files \(1 art\) · /m);
assert.match(r.stdout, /^update: v9\.9\.9 over v9\.9\.8$/m);
assert.match(r.stdout, /^ {2}vs v9\.9\.8: 5 changed, 1 added, \d+ unchanged, 1 removed$/m);
assert.match(r.stdout, /^removed: 1 file\(s\) \(server\/old\.js\)$/m);
const upZip = path.join(out, `${FOLDER}-v9.9.9-update.zip`);
assert.deepEqual(fs.readdirSync(out).sort(), [`${FOLDER}-v9.9.8.zip`, `${FOLDER}-v9.9.9-update.zip`], 'both stages removed');
const got = new Map();
readZip(upZip, (name, data) => got.set(name, data));
const SHIP = ['package-lock.json', 'package.json', 'public/assets/char/a.png', 'server/new.js', 'server/sim/rng.js', 'shared/constants.js'];
assert.deepEqual([...got.keys()].sort(), [...SHIP, MANIFEST_FILE, UPDATE_FILE].map((f) => `${FOLDER}/${f}`).sort());
const u = parseUpdate(got.get(`${FOLDER}/${UPDATE_FILE}`).toString('utf8'));
assert.deepEqual([u.app, u.from, u.count, [...u.files.keys()]], ['9.9.9', ['9.9.8'], 6, SHIP]);
assert.equal(u.bytes, SHIP.reduce((n, f) => n + got.get(`${FOLDER}/${f}`).length, 0));
assert.deepEqual(u.removed, [{ path: 'server/old.js', sha256: [sha('export const old = 1;\n')] }]);
// the full and lite zips of the same version carry the same MANIFEST.json, listing the non-art files
r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty']);
assert.equal(r.status, 0, r.stdout + r.stderr);
r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--lite']);
assert.equal(r.status, 0, r.stdout + r.stderr);
const manifestOf = (zip) => { let m = null; readZip(path.join(out, zip), (n, d) => { if (n === `${FOLDER}/${MANIFEST_FILE}`) m = d.toString('utf8'); }); return m; };
const m = manifestOf(`${FOLDER}-v9.9.9.zip`);
assert.equal(manifestOf(`${FOLDER}-v9.9.9-lite.zip`), m);
assert.equal(got.get(`${FOLDER}/${MANIFEST_FILE}`).toString('utf8'), m);
const listed = JSON.parse(m);
assert.equal(listed.app, '9.9.9');
assert.deepEqual(Object.keys(listed.files).sort(), ['README.md', 'data/chess.json', 'package-lock.json', 'package.json', 'public/index.html',
'scripts/launch.mjs', 'server/index.js', 'server/new.js', 'server/sim/rng.js', 'shared/constants.js', 'tools/doctor.mjs',
'tools/fetch-assets.mjs', 'tools/setup.mjs', 'tools/vendor.mjs'], 'no art, no data/assets.json');
assert.deepEqual(listed.files['server/new.js'], dg('export const n = 1;\n'));
// the player: the base extracted, a pack of their own, the update extracted over it, the server started
extract(baseZip, inst);
const root = path.join(inst, FOLDER);
put(root, 'packs/mine/pack.json', '{"type":"lang"}');
extract(upZip, inst);
assert.equal(checkInstall(root).state, 'ok', 'the extracted update already matches its manifest');
const q = quiet();
const done = applyPendingUpdate(root, { log: q.log });
assert.equal(done.state, 'applied', JSON.stringify(q.lines));
assert.deepEqual(done.deleted, ['server/old.js']);
assert.ok(!has(root, 'server/old.js') && has(root, APPLIED_FILE) && !has(root, UPDATE_FILE) && has(root, 'packs/mine/pack.json'));
assert.equal(read(root, 'shared/constants.js'), "export const APP_VERSION = '9.9.9';\n");
// the bases a later update may name: the full zip and the folder it was extracted to read the same
const asZip = readBase(path.join(out, `${FOLDER}-v9.9.9.zip`));
const fresh = tmp('fresh');
try {
extract(path.join(out, `${FOLDER}-v9.9.9.zip`), fresh);
put(fresh, `${FOLDER}/.DS_Store`, 'finder');
const asFolder = readBases([fresh], '9.9.10')[0];
assert.deepEqual([...asFolder.files.keys()].sort(), [...asZip.files.keys()].filter((f) => f !== MANIFEST_FILE).sort(), 'MANIFEST.json and clutter left out');
assert.equal(asFolder.version, '9.9.9');
} finally {
fs.rmSync(fresh, { recursive: true, force: true });
}
} finally {
for (const d of [co.dir, out, inst]) fs.rmSync(d, { recursive: true, force: true });
}
});
test('bases are refused when they cannot be one: lite, an update zip, not older, twice, not a release; the options go together', { skip: !hasZipTool && 'no zip / tar' }, () => {
const co = fakeCheckout('9.9.8');
const out = tmp('out');
try {
assert.equal(runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty']).status, 0);
assert.equal(runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--lite']).status, 0);
const full = path.join(out, `${FOLDER}-v9.9.8.zip`);
const lite = path.join(out, `${FOLDER}-v9.9.8-lite.zip`);
bump(co, '9.9.9');
put(co.dir, 'server/sim/rng.js', 'export const rng = 2;\n');
assert.equal(co.git('add', '-A').status, 0);
assert.equal(runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--update', '--from', full]).status, 0);
const update = path.join(out, `${FOLDER}-v9.9.9-update.zip`);
const refuse = (from, re) => {
const r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--update', '--force', '--dry-run', '--from', from]);
assert.equal(r.status, 1, r.stdout);
assert.match(r.stderr, re);
};
refuse(lite, /has no game art \(a lite zip\?\): pass the full zip, Stronghold-Protocol-v9\.9\.8\.zip/);
refuse(update, /is an update package: pass the earlier release's full zip/);
refuse(`${full},${full}`, /two bases are v9\.9\.8/);
refuse(path.join(out, 'nothing.zip'), /not found/);
fs.writeFileSync(path.join(out, 'other.zip'), 'not a zip');
refuse(path.join(out, 'other.zip'), /not a zip file/);
assert.throws(() => readBases([full], '9.9.8'), /is v9\.9\.8, not older than v9\.9\.8/);
const dry = runTool(['--root', co.dir, '--out', out, '--allow-dirty', '--update', '--dry-run', '--from', full]);
assert.equal(dry.status, 0, dry.stderr);
assert.match(dry.stdout, /^update: the bases are fine/m);
for (const args of [['--from', full], ['--update'], ['--update', '--lite', '--from', full]]) {
const r = runTool(['--root', co.dir, '--dry-run', ...args]);
assert.equal(r.status, 2, args.join(' '));
}
} finally {
fs.rmSync(co.dir, { recursive: true, force: true });
fs.rmSync(out, { recursive: true, force: true });
}
});
test('a base folder: npm\'s files are compared like the stage\'s, per-machine and clutter files left out; no update without npm ci', { skip: !hasZipTool && 'no zip / tar' }, () => {
const co = fakeCheckout('9.9.8');
const out = tmp('out');
const base = tmp('base');
try {
assert.equal(runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty']).status, 0);
extract(path.join(out, `${FOLDER}-v9.9.8.zip`), base);
const root = path.join(base, FOLDER);
put(root, 'logs/server.log', 'a log'); // per-machine: never part of a base
put(root, 'scripts/service.env.cmd', 'set PORT=3000');
put(root, 'public/.DS_Store', 'finder');
put(root, 'node_modules/ws/.DS_Store', 'npm ships what it ships');
const b = readBases([root], '9.9.9')[0];
assert.ok(![...b.files.keys()].some((f) => /^(?:logs|scripts\/service|public\/\.DS)/.test(f)));
assert.ok(b.files.has('node_modules/ws/.DS_Store'), 'node_modules is taken as npm wrote it, like the stage');
// a base with node_modules and a build without npm ci: refused, it would delete them everywhere
bump(co, '9.9.9');
assert.equal(co.git('add', '-A').status, 0);
const r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--update', '--from', root]);
assert.equal(r.status, 1, r.stdout);
assert.match(r.stderr, /v9\.9\.8 ships node_modules\/ but this build has none \(--no-install\?\): an update built so would delete it from every install/);
} finally {
for (const d of [co.dir, out, base]) fs.rmSync(d, { recursive: true, force: true });
}
});
test('the zip reader: stored and deflated entries, zip64, UTF-8 names; damaged and foreign zips are refused', { skip: !hasZipTool && 'no zip / tar' }, () => {
const dir = tmp('zip');
try {
put(dir, `${FOLDER}/package.json`, '{"version":"0.2.0"}');
put(dir, `${FOLDER}/public/assets/卫戍 a.png`, Buffer.alloc(5000, 7));
put(dir, `${FOLDER}/server/empty.js`, '');
const zipped = [];
const make = (name, args) => {
const z = spawnSync('zip', ['-q', '-r', ...args, name, FOLDER], { cwd: dir });
if (!z.error && z.status === 0) zipped.push(name);
};
make('plain.zip', ['-X']);
make('stored.zip', ['-X', '-0']);
make('zip64.zip', ['-X', '-fz']);
if (!zipped.length) {
const t = spawnSync('tar', ['-c', '--format', 'zip', '-f', 'plain.zip', FOLDER], { cwd: dir });
if (!t.error && t.status === 0) zipped.push('plain.zip');
}
assert.ok(zipped.length > 0);
for (const z of zipped) {
const b = readBase(path.join(dir, z));
assert.deepEqual([b.version, b.art, b.update], ['0.2.0', 1, false], z);
assert.deepEqual([...b.files.keys()].sort(), ['package.json', 'public/assets/卫戍 a.png', 'server/empty.js'], z);
assert.deepEqual(b.files.get('public/assets/卫戍 a.png'), dg(Buffer.alloc(5000, 7)), z);
assert.deepEqual(b.files.get('server/empty.js'), dg(''), z);
}
// one damaged byte in a stored entry: the CRC refuses it (zlib.crc32, Node ≥ 22.2)
if (zipped.includes('stored.zip') && typeof zlib.crc32 === 'function') {
const bytes = fs.readFileSync(path.join(dir, 'stored.zip'));
bytes[bytes.indexOf(Buffer.alloc(64, 7)) + 100] ^= 0xff;
fs.writeFileSync(path.join(dir, 'bad.zip'), bytes);
assert.throws(() => readBase(path.join(dir, 'bad.zip')), /bad\.zip: Stronghold-Protocol\/public\/assets\/卫戍 a\.png: CRC mismatch/);
}
const cut = fs.readFileSync(path.join(dir, zipped[0]));
fs.writeFileSync(path.join(dir, 'cut.zip'), cut.subarray(0, cut.length - 30));
assert.throws(() => readBase(path.join(dir, 'cut.zip')), /cut\.zip: (?:not a zip file|damaged|unexpected end)/, 'a truncated download');
// entries outside the one folder: not a release zip
put(dir, 'other/x.txt', 'x');
const z = spawnSync('zip', ['-q', '-r', 'foreign.zip', 'other'], { cwd: dir });
if (!z.error && z.status === 0) assert.throws(() => readBase(path.join(dir, 'foreign.zip')), /outside the Stronghold-Protocol\/ folder/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});