import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import fsp from 'node:fs/promises' import http from 'node:http' import os from 'node:os' import path from 'node:path' import { REPO_ROOT, buildManifestPayload, compareVersions, createSignedManifest, normalizeBaseUrl, readPrivateKey, readPublicKey, renderDownloadPage, runProcess, serializeSignedManifest, signPayload, verifyPayloadSignature, verifySignedManifest } from './lib.mjs' import { publishLocalRelease, readLocalLatest } from './transport.mjs' let passed = 0 async function test(name, callback) { await callback() passed += 1 console.log(`PASS ${name}`) } function expectedError(pattern) { return (error) => { assert.match(String(error?.message || error), pattern) return true } } function sha256(buffer) { return createHash('sha256').update(buffer).digest('hex') } function contentType(filename) { return filename.endsWith('.json') ? 'application/json' : 'application/octet-stream' } function startStaticServer(root) { const server = http.createServer(async (request, response) => { try { const url = new URL(request.url, 'http://127.0.0.1') const pathname = decodeURIComponent(url.pathname) const relative = pathname.startsWith('/ycode/releases/') ? pathname.slice('/ycode/releases/'.length) : null assert(relative !== null, 'unexpected path') const requested = relative === '' || relative.endsWith('/') ? `${relative}index.html` : relative const filename = path.resolve(root, 'ycode', 'releases', requested) const releaseRoot = path.resolve(root, 'ycode', 'releases') assert(filename.startsWith(`${releaseRoot}${path.sep}`), 'path traversal') const data = await fsp.readFile(filename) response.writeHead(200, { 'content-length': data.length, 'content-type': contentType(filename) }) response.end(data) } catch { response.writeHead(404) response.end('not found') } }) return new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, '127.0.0.1', () => { const address = server.address() resolve({ server, origin: `http://127.0.0.1:${address.port}` }) }) }) } async function releaseFor({ version, bytes, filename, baseUrl, privatePem, publicPem, artifactPath }) { const installer = { filename, size: bytes.length, sha256: sha256(bytes) } const payload = buildManifestPayload({ version, publishedAt: '2026-10-20T10:00:00.000Z', notes: { zh: `版本 ${version} 测试说明`, en: `Release ${version} test notes` }, installer, baseUrl }) const document = createSignedManifest({ payload, privateKeyPem: privatePem, publicKeyPem: publicPem, baseUrl }) return { document, installer: { ...installer, path: artifactPath }, manifestText: serializeSignedManifest(document, { baseUrl }), publicKeyPem: publicPem, baseUrl } } async function main() { const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-selftest-')) const privateKey = path.join(temporary, 'keys', 'update-signing.key') const publicKey = path.join(temporary, 'keys', 'public-key.pem') const publicRoot = path.join(temporary, 'ycode') const artifactDirectory = path.join(temporary, 'fixtures') await fsp.mkdir(artifactDirectory, { recursive: true }) let server try { await test('key generation stays outside the repository and writes a PEM trust anchor', async () => { const initKey = path.join(REPO_ROOT, 'scripts', 'updates', 'init-key.mjs') const output = runProcess(process.execPath, [initKey, '--private-key', privateKey, '--public-key', publicKey]) assert.match(output, /Private key:/) assert.doesNotMatch(output, /BEGIN PRIVATE KEY/) const privatePem = await fsp.readFile(privateKey, 'utf8') const publicPem = await fsp.readFile(publicKey, 'utf8') assert.match(privatePem, /BEGIN PRIVATE KEY/) assert.match(publicPem, /BEGIN PUBLIC KEY/) if (process.platform === 'win32') { const acl = runProcess('icacls', [privateKey]) assert.doesNotMatch(acl, /\(I\)/) } }) const privatePem = await readPrivateKey(privateKey) const publicPem = await readPublicKey(publicKey) await test('SemVer precedence is strict and build metadata does not create a newer release', async () => { assert(compareVersions('2.0.0', '1.9.9') > 0) assert(compareVersions('2.0.0-rc.2', '2.0.0-rc.10') < 0) assert(compareVersions('2.0.0+1', '2.0.0+2') === 0) assert.throws(() => compareVersions('01.0.0', '1.0.0'), /strict SemVer/) assert.throws(() => compareVersions('1.0', '1.0.0'), /strict SemVer/) }) const staticServer = await startStaticServer(temporary) server = staticServer.server const baseUrl = normalizeBaseUrl(`${staticServer.origin}/ycode/releases/`) const version1 = '2.0.0' const bytes1 = Buffer.from(`fake installer ${version1} `.repeat(16_384), 'utf8') const filename1 = `YCode-${version1}-Setup-x64.exe` const artifact1 = path.join(artifactDirectory, filename1) await fsp.writeFile(artifact1, bytes1) const release1 = await releaseFor({ version: version1, bytes: bytes1, filename: filename1, baseUrl, privatePem, publicPem, artifactPath: artifact1 }) await test('manifest signs exact JSON.stringify(payload) and rejects any payload or signature tamper', async () => { verifySignedManifest(release1.document, publicPem, { baseUrl }) assert(verifyPayloadSignature(release1.document.payload, release1.document.signature, publicPem)) const notesTamper = structuredClone(release1.document) notesTamper.payload.notes.en = 'tampered' assert.throws(() => verifySignedManifest(notesTamper, publicPem, { baseUrl }), /signature is invalid/) const hashTamper = structuredClone(release1.document) hashTamper.payload.platforms['windows-x64'].sha256 = '0'.repeat(64) assert.throws(() => verifySignedManifest(hashTamper, publicPem, { baseUrl }), /signature is invalid/) const signatureTamper = structuredClone(release1.document) const first = signatureTamper.signature[0] === 'A' ? 'B' : 'A' signatureTamper.signature = first + signatureTamper.signature.slice(1) assert.throws(() => verifySignedManifest(signatureTamper, publicPem, { baseUrl }), /signature is invalid/) }) await test('signed manifests reject query, fragment, credentials, cross-version paths and non-canonical timestamps', async () => { const cases = [ (payload) => { payload.platforms['windows-x64'].url += '?token=bad' }, (payload) => { payload.platforms['windows-x64'].url += '#fragment' }, (payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('127.0.0.1', 'user:pass@127.0.0.1') }, (payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('/versions/2.0.0/', '/versions/2.0.1/') }, (payload) => { payload.publishedAt = '2026-10-20T10:00:00Z' }, (payload) => { payload.platforms['windows-x64'].sha256 = payload.platforms['windows-x64'].sha256.toUpperCase() } ] for (const mutate of cases) { const payload = structuredClone(release1.document.payload) mutate(payload) const document = { payload, signature: signPayload(payload, privatePem) } assert.throws(() => verifySignedManifest(document, publicPem, { baseUrl }), /Manifest|URL|publishedAt|sha256/) } }) await test('download page escapes signed notes and contains no script', async () => { const payload = structuredClone(release1.document.payload) payload.notes.zh = '\n第二行' payload.notes.en = '' const html = renderDownloadPage(payload, { baseUrl }) assert.doesNotMatch(html, /