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, /