feat: ship skill and MCP workflows with signed updater for v0.0.7
This commit is contained in:
1 parent
1b52df0603
commit
a01085c656
136 files changed
+18646
-217
No files matched your search
@@ -0,0 +1,372 @@
|
||||
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
|
||||
})
|
||||
Reference in new issue
Block a user