373 lines
15 KiB
JavaScript
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, /<script>/)
|
|
assert.match(html, /<img src=x onerror=alert\(1\)>/)
|
|
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
|
|
})
|