Files
ycode/scripts/updates/selftest.mjs
T

373 lines
15 KiB
JavaScript

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 = '<script>alert("zh")</script>\n第二行'
payload.notes.en = '<img src=x onerror=alert(1)>'
const html = renderDownloadPage(payload, { baseUrl })
assert.doesNotMatch(html, /<script>alert/)
assert.doesNotMatch(html, /<img src=x/)
assert.match(html, /&lt;script&gt;/)
assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/)
assert.match(html, /Content-Security-Policy/)
})
await test('local fake transport publishes immutable artifact then fetches and verifies it over HTTP', async () => {
let current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release: release1, current })
const response = await fetch(new URL('latest.json', baseUrl))
assert.equal(response.status, 200)
assert.equal(response.headers.get('cache-control'), null)
const text = await response.text()
const document = JSON.parse(text)
verifySignedManifest(document, publicPem, { baseUrl })
const artifactResponse = await fetch(document.payload.platforms['windows-x64'].url)
const downloaded = Buffer.from(await artifactResponse.arrayBuffer())
assert.equal(sha256(downloaded), document.payload.platforms['windows-x64'].sha256)
assert.equal(downloaded.length, document.payload.platforms['windows-x64'].size)
const pageResponse = await fetch(new URL('./', baseUrl))
assert.equal(pageResponse.status, 200)
const page = await pageResponse.text()
assert.match(page, /v2\.0\.0/)
assert.match(page, new RegExp(document.payload.platforms['windows-x64'].sha256))
assert.doesNotMatch(page, /<script\b/i)
})
await test('exact redeploy is idempotent', async () => {
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release: release1, current })
})
await test('a conflicting artifact in an existing version directory is rejected', async () => {
const bytes = Buffer.from('conflicting same-version bytes', 'utf8')
const artifact = path.join(artifactDirectory, filename1)
await fsp.writeFile(artifact, bytes)
const conflicting = await releaseFor({
version: version1,
bytes,
filename: filename1,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(current.document.payload.version, version1)
await fsp.writeFile(artifact, bytes1)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
expectedError(/different manifest/)
)
})
await test('a lower version is rejected without changing latest.json', async () => {
const bytes = Buffer.from('lower release fixture', 'utf8')
const filename = 'YCode-1.9.9-Setup-x64.exe'
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const lower = await releaseFor({
version: '1.9.9',
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: lower, current }),
expectedError(/newer/)
)
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(after.raw, current.raw)
})
await test('failure before latest.json swap leaves the previous signed manifest live', async () => {
const version = '2.1.0'
const bytes = Buffer.from('failed atomic fixture', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const failed = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const before = await readLocalLatest(publicRoot, publicPem, baseUrl)
const pageBefore = await (await fetch(new URL('./', baseUrl))).text()
await assert.rejects(
publishLocalRelease({
root: publicRoot,
release: failed,
current: before,
beforeLatestSwap: async () => { throw new Error('injected publication failure') }
}),
expectedError(/injected publication failure/)
)
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(after.raw, before.raw)
const pageAfter = await (await fetch(new URL('./', baseUrl))).text()
assert.equal(pageAfter, pageBefore)
})
await test('a different artifact cannot reuse a version directory left by a failed publication', async () => {
const version = '2.1.0'
const bytes = Buffer.from('different failed artifact', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const conflicting = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
expectedError(/conflicting artifact/)
)
})
await test('successful publication swaps the generated download page with latest.json', async () => {
const version = '2.2.0'
const bytes = Buffer.from('successful next release fixture', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const release = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release, current })
const latest = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(latest.document.payload.version, version)
const page = await (await fetch(new URL('./', baseUrl))).text()
assert.match(page, /v2\.2\.0/)
assert.match(page, new RegExp(latest.document.payload.platforms['windows-x64'].sha256))
})
console.log(`\n${passed} release-tool self-tests passed. No SSH target or production latest.json was touched.`)
} finally {
if (server) await new Promise((resolve) => server.close(resolve))
await fsp.rm(temporary, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})