Files
ycode/scripts/updates/lib.mjs
T

539 lines
22 KiB
JavaScript

import crypto from 'node:crypto'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
export const DEFAULT_BASE_URL = 'http://192.168.10.6/ycode/releases/'
export const DEFAULT_SSH_HOST = 'local001'
export const DEFAULT_PRIVATE_KEY = path.join(os.homedir(), '.ycode-release', 'update-signing.key')
export const DEFAULT_PUBLIC_KEY = path.join(REPO_ROOT, 'resources', 'updates', 'public-key.pem')
export const BASELINE_VERSION = '0.0.1'
export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024
export const MIN_PUBLIC_INSTALLER_BYTES = 20 * 1024 * 1024
export const SERVER_PUBLIC_ROOT = '/srv/ycode'
export const SERVER_STAGING_ROOT = '/srv/ycode-staging'
export const SERVER_RELEASES_ROOT = '/srv/ycode/releases'
export const NGINX_SITE_LINK = '/etc/nginx/sites-enabled/default'
export const NGINX_SNIPPET = '/etc/nginx/snippets/ycode-releases.conf'
export const NGINX_MARKER = '# YCode updater releases (managed by scripts/updates)'
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
const SHA256_RE = /^[a-f0-9]{64}$/
const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/
function fail(message) {
throw new Error(message)
}
export function assert(condition, message) {
if (!condition) fail(message)
}
export function parseOptions(argv, { values = [], booleans = [] } = {}) {
const valueNames = new Set(values)
const booleanNames = new Set(booleans)
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (!argument.startsWith('--')) fail(`Unexpected argument: ${argument}`)
const equals = argument.indexOf('=')
const name = argument.slice(2, equals === -1 ? undefined : equals)
if (booleanNames.has(name)) {
assert(equals === -1, `--${name} does not accept a value`)
options[name] = true
continue
}
assert(valueNames.has(name), `Unknown option: --${name}`)
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
assert(value !== undefined && value !== '', `Missing value for --${name}`)
options[name] = value
}
return options
}
export function normalizeBaseUrl(value = DEFAULT_BASE_URL) {
let url
try {
url = new URL(value)
} catch {
fail(`Invalid base URL: ${value}`)
}
assert(url.protocol === 'http:' || url.protocol === 'https:', 'Base URL must use HTTP or HTTPS')
assert(!url.username && !url.password, 'Base URL must not contain credentials')
assert(!url.search && !url.hash, 'Base URL must not contain a query or fragment')
if (!url.pathname.endsWith('/')) url.pathname += '/'
return url.href
}
export function artifactUrlFor(baseUrl, version, filename) {
parseVersion(version)
assert(EXE_NAME_RE.test(filename), `Unsafe installer filename: ${filename}`)
const base = normalizeBaseUrl(baseUrl)
return new URL(`versions/${version}/${filename}`, base).href
}
export function assertHttpUrl(value, label = 'URL') {
let url
try {
url = new URL(value)
} catch {
fail(`${label} is invalid`)
}
assert(url.protocol === 'http:' || url.protocol === 'https:', `${label} must use HTTP or HTTPS`)
assert(!url.username && !url.password, `${label} must not contain credentials`)
assert(!url.search, `${label} must not contain a query`)
assert(!url.hash, `${label} must not contain a fragment`)
return url
}
export function parseVersion(version) {
assert(typeof version === 'string' && version.length <= 256, 'Version must be a non-empty string')
const match = SEMVER_RE.exec(version)
assert(match, `Version is not strict SemVer: ${version}`)
const prerelease = match[4] ? match[4].split('.') : []
for (const identifier of prerelease) {
assert(!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0'), `Invalid SemVer prerelease: ${version}`)
}
return {
major: BigInt(match[1]),
minor: BigInt(match[2]),
patch: BigInt(match[3]),
prerelease,
build: match[5] ? match[5].split('.') : []
}
}
function compareIdentifier(left, right) {
const leftNumeric = /^\d+$/.test(left)
const rightNumeric = /^\d+$/.test(right)
if (leftNumeric && rightNumeric) {
const a = BigInt(left)
const b = BigInt(right)
return a < b ? -1 : a > b ? 1 : 0
}
if (leftNumeric) return -1
if (rightNumeric) return 1
return left < right ? -1 : left > right ? 1 : 0
}
export function compareVersions(left, right) {
const a = parseVersion(left)
const b = parseVersion(right)
for (const key of ['major', 'minor', 'patch']) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
}
if (!a.prerelease.length && !b.prerelease.length) return 0
if (!a.prerelease.length) return 1
if (!b.prerelease.length) return -1
const length = Math.max(a.prerelease.length, b.prerelease.length)
for (let index = 0; index < length; index += 1) {
if (a.prerelease[index] === undefined) return -1
if (b.prerelease[index] === undefined) return 1
const compared = compareIdentifier(a.prerelease[index], b.prerelease[index])
if (compared !== 0) return compared
}
return 0
}
export function normalizeProductVersion(value) {
let normalized = String(value ?? '').trim()
if (normalized.startsWith('v')) normalized = normalized.slice(1)
const windowsRevision = /^(\d+\.\d+\.\d+)\.0$/.exec(normalized)
if (windowsRevision) normalized = windowsRevision[1]
return normalized
}
export async function sha256File(filename) {
const hash = crypto.createHash('sha256')
for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
return hash.digest('hex')
}
export async function readJson(filename) {
try {
return JSON.parse(await fsp.readFile(filename, 'utf8'))
} catch (error) {
fail(`Unable to read JSON ${filename}: ${error.message}`)
}
}
export async function writeFileAtomic(filename, data, mode = 0o644) {
const directory = path.dirname(filename)
await fsp.mkdir(directory, { recursive: true })
const temporary = path.join(directory, `.${path.basename(filename)}.${crypto.randomBytes(8).toString('hex')}.tmp`)
const handle = await fsp.open(temporary, 'wx', mode)
try {
await handle.writeFile(data)
await handle.sync()
} finally {
await handle.close()
}
try {
await fsp.rename(temporary, filename)
} catch (error) {
await fsp.rm(temporary, { force: true })
throw error
}
}
export function resolvePath(value, base = process.cwd()) {
return path.resolve(base, value)
}
export async function assertOutsideRepo(filename, label) {
const candidate = path.resolve(filename)
const repo = path.resolve(REPO_ROOT)
const relative = path.relative(repo, candidate)
assert(relative.startsWith('..') || path.isAbsolute(relative), `${label} must be outside the repository: ${candidate}`)
return candidate
}
export function generateEd25519KeyPair() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
return {
privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString()
}
}
export function createPrivateKey(privateKeyPem) {
const key = crypto.createPrivateKey(privateKeyPem)
assert(key.asymmetricKeyType === 'ed25519', 'Update signing key must be Ed25519')
return key
}
export function createPublicKey(publicKeyPem) {
const key = crypto.createPublicKey(publicKeyPem)
assert(key.asymmetricKeyType === 'ed25519', 'Update public key must be Ed25519')
return key
}
export function signPayload(payload, privateKeyPem) {
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
return crypto.sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64')
}
export function verifyPayloadSignature(payload, signature, publicKeyPem) {
assert(typeof signature === 'string', 'Manifest signature must be a string')
const signatureBytes = Buffer.from(signature, 'base64')
assert(signatureBytes.length === 64, 'Manifest signature must contain 64 bytes')
assert(signatureBytes.toString('base64') === signature, 'Manifest signature must use canonical base64')
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
return crypto.verify(null, bytes, createPublicKey(publicKeyPem), signatureBytes)
}
export function publicKeyFingerprint(publicKeyPem) {
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
return crypto.createHash('sha256').update(der).digest('hex')
}
function assertExactKeys(value, expected, label) {
assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`)
const actual = Object.keys(value)
assert(actual.length === expected.length && expected.every((key) => actual.includes(key)), `${label} has unexpected or missing fields`)
}
export function validateManifestDocument(document, { baseUrl = DEFAULT_BASE_URL } = {}) {
assertExactKeys(document, ['payload', 'signature'], 'Manifest')
const payload = document.payload
assertExactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], 'Manifest payload')
assert(payload.schemaVersion === 1, 'Unsupported update manifest schemaVersion')
parseVersion(payload.version)
assert(typeof payload.publishedAt === 'string', 'publishedAt must be a string')
const published = new Date(payload.publishedAt)
assert(!Number.isNaN(published.valueOf()), 'publishedAt must be an ISO timestamp')
assert(published.toISOString() === payload.publishedAt, 'publishedAt must be canonical UTC ISO')
assertExactKeys(payload.notes, ['zh', 'en'], 'Manifest notes')
for (const language of ['zh', 'en']) {
assert(typeof payload.notes[language] === 'string' && payload.notes[language].trim(), `Manifest notes.${language} is required`)
assert(payload.notes[language].length <= 16_384, `Manifest notes.${language} is too long`)
}
assertExactKeys(payload.platforms, ['windows-x64'], 'Manifest platforms')
const platform = payload.platforms['windows-x64']
assertExactKeys(platform, ['url', 'size', 'sha256'], 'windows-x64 platform')
assert(Number.isSafeInteger(platform.size) && platform.size > 0 && platform.size <= MAX_INSTALLER_BYTES, 'windows-x64 size is invalid')
assert(typeof platform.sha256 === 'string' && SHA256_RE.test(platform.sha256), 'windows-x64 sha256 must be lowercase SHA-256 hex')
const url = assertHttpUrl(platform.url, 'windows-x64 URL')
const filename = path.posix.basename(url.pathname)
const expected = artifactUrlFor(baseUrl, payload.version, filename)
assert(url.href === expected, 'windows-x64 URL is outside the approved version path')
return payload
}
export function verifySignedManifest(document, publicKeyPem, options = {}) {
const payload = validateManifestDocument(document, options)
assert(verifyPayloadSignature(payload, document.signature, publicKeyPem), 'Update manifest signature is invalid')
return payload
}
export function serializeSignedManifest(document, options = {}) {
validateManifestDocument(document, options)
return `${JSON.stringify(document)}\n`
}
export async function readPublicKey(filename = DEFAULT_PUBLIC_KEY) {
try {
return await fsp.readFile(filename, 'utf8')
} catch (error) {
fail(`Unable to read update public key ${filename}: ${error.message}`)
}
}
export async function readPrivateKey(filename = DEFAULT_PRIVATE_KEY) {
try {
return await fsp.readFile(filename, 'utf8')
} catch (error) {
fail(`Unable to read update signing key ${filename}: ${error.message}`)
}
}
export function runProcess(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
input: options.input,
encoding: 'utf8',
windowsHide: true,
maxBuffer: 8 * 1024 * 1024
})
if (result.error) fail(`Unable to run ${command}: ${result.error.message}`)
if (result.status !== 0) {
const detail = String(result.stderr || result.stdout || '').trim()
fail(`${command} failed with exit code ${result.status}${detail ? `: ${detail}` : ''}`)
}
return result.stdout
}
export function validateSshHost(host = DEFAULT_SSH_HOST) {
assert(/^[A-Za-z0-9_.@:-]+$/.test(host) && !host.startsWith('-'), 'SSH host contains unsafe characters')
return host
}
export function runSsh(host, script) {
return runProcess('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', validateSshHost(host), 'bash', '-s'], {
input: script
})
}
export async function getProductVersion(installerPath) {
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'get-exe-version.ps1')
assert(fs.existsSync(script), `Missing version helper: ${script}`)
const output = runProcess('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
script,
'-Path',
installerPath
])
let parsed
try {
parsed = JSON.parse(output)
} catch {
fail('Unable to parse installer version information')
}
assert(parsed && typeof parsed.productVersion === 'string', 'Installer has no ProductVersion')
return parsed
}
export async function readPackageMetadata() {
const packagePath = path.join(REPO_ROOT, 'package.json')
const pkg = await readJson(packagePath)
assert(pkg.name === 'ycode', 'package.json is not the YCode package')
assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
parseVersion(pkg.version)
return { path: packagePath, package: pkg }
}
export async function verifyReleaseInstaller(installerPath, version, { minimumSize = MIN_PUBLIC_INSTALLER_BYTES } = {}) {
parseVersion(version)
assert(compareVersions(version, BASELINE_VERSION) > 0, `Release version must be newer than the updater-less ${BASELINE_VERSION} baseline`)
assert(EXE_NAME_RE.test(path.basename(installerPath)), `Unsafe installer filename: ${path.basename(installerPath)}`)
let resolved
try {
resolved = await fsp.realpath(installerPath)
} catch (error) {
fail(`Unable to resolve installer: ${error.message}`)
}
const stat = await fsp.stat(resolved)
assert(stat.isFile(), 'Installer must be a regular file')
assert(stat.size >= minimumSize, `Installer is unexpectedly small (${stat.size} bytes)`)
assert(stat.size <= MAX_INSTALLER_BYTES, 'Installer exceeds the 4 GiB limit')
const product = await getProductVersion(resolved)
const productVersion = normalizeProductVersion(product.productVersion)
assert(productVersion === version, `Installer ProductVersion ${product.productVersion} does not match ${version}`)
const { path: packagePath, package: pkg } = await readPackageMetadata()
assert(normalizeProductVersion(pkg.version) === version, `package.json version ${pkg.version} does not match ${version}`)
return {
path: resolved,
filename: path.basename(resolved),
size: stat.size,
sha256: await sha256File(resolved),
productVersion,
packagePath
}
}
export function buildManifestPayload({ version, publishedAt, notes, installer, baseUrl = DEFAULT_BASE_URL }) {
const url = artifactUrlFor(baseUrl, version, installer.filename)
return {
schemaVersion: 1,
version,
publishedAt,
notes: {
zh: notes.zh,
en: notes.en
},
platforms: {
'windows-x64': {
url,
size: installer.size,
sha256: installer.sha256
}
}
}
}
export function createSignedManifest({ payload, privateKeyPem, publicKeyPem, baseUrl = DEFAULT_BASE_URL }) {
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, { baseUrl })
const signature = signPayload(payload, privateKeyPem)
const document = { payload, signature }
assert(verifyPayloadSignature(payload, signature, publicKeyPem), 'Generated manifest does not verify with the configured public key')
validateManifestDocument(document, { baseUrl })
return document
}
export function canonicalNow(value = new Date()) {
return value.toISOString()
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function formatBytes(value) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB (${value.toLocaleString('en-US')} bytes)`
}
export function renderDownloadPage(payload, options = {}) {
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, options)
const platform = payload.platforms['windows-x64']
const version = escapeHtml(payload.version)
const url = escapeHtml(platform.url)
const sha256 = escapeHtml(platform.sha256)
const publishedAt = escapeHtml(payload.publishedAt)
const zhNotes = escapeHtml(payload.notes.zh)
const enNotes = escapeHtml(payload.notes.en)
const size = escapeHtml(formatBytes(platform.size))
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>YCode v${version} 下载</title>
<style>
:root { color-scheme: light; --ink:#17201b; --muted:#667069; --paper:#f4f1e8; --card:#fffdf7; --line:#d9d4c6; --accent:#176b4d; --accent-dark:#0d4d36; }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; background:radial-gradient(circle at top left, #e2eadf 0, transparent 34rem), var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI","Noto Sans CJK SC",sans-serif; }
main { width:min(760px, calc(100% - 32px)); margin:0 auto; padding:clamp(48px,9vw,96px) 0; }
.eyebrow { margin:0 0 16px; color:var(--accent-dark); font-size:12px; font-weight:700; letter-spacing:.18em; text-transform:uppercase; }
h1 { margin:0; font-size:clamp(36px,7vw,64px); line-height:1; letter-spacing:-.045em; }
.summary { margin:18px 0 30px; color:var(--muted); font-size:16px; line-height:1.7; }
.card { border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); overflow:hidden; }
.release { padding:clamp(22px,4vw,34px); border-bottom:1px solid var(--line); }
.meta { display:flex; flex-wrap:wrap; gap:8px 18px; margin:0 0 22px; color:var(--muted); font-size:13px; }
.download { display:inline-flex; align-items:center; justify-content:center; min-height:46px; padding:0 22px; border-radius:999px; background:var(--accent); color:white; font-weight:700; text-decoration:none; }
.download:hover { background:var(--accent-dark); }
.hash { display:block; overflow-wrap:anywhere; margin-top:16px; padding:12px 14px; border:1px solid var(--line); border-radius:10px; background:#f8f6ef; color:#39443d; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; }
.notes { padding:clamp(22px,4vw,34px); display:grid; gap:24px; }
.notes h2 { margin:0 0 8px; font-size:14px; letter-spacing:.04em; }
.notes p { margin:0; color:#3e4841; line-height:1.75; white-space:pre-wrap; }
.footer { margin-top:20px; color:var(--muted); font-size:12px; line-height:1.7; }
code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
</style>
</head>
<body>
<main>
<p class="eyebrow">YCode / Windows x64</p>
<h1>v${version}</h1>
<p class="summary">此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。</p>
<section class="card">
<div class="release">
<div class="meta">
<span>发布时间 ${publishedAt}</span>
<span>${size}</span>
</div>
<a class="download" href="${url}" rel="noopener">下载 Windows x64 安装包</a>
<code class="hash">SHA-256 ${sha256}</code>
</div>
<div class="notes">
<div>
<h2>更新说明</h2>
<p lang="zh-CN">${zhNotes}</p>
</div>
<div>
<h2>Release notes</h2>
<p lang="en">${enNotes}</p>
</div>
</div>
</section>
<p class="footer">请核对安装包 SHA-256。更新客户端会独立验证签名、文件大小和哈希。<br>
<a href="latest.json">查看签名清单</a></p>
</main>
</body>
</html>
`
}
export function renderNoReleasePage() {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>YCode 下载</title>
<style>
:root { --paper:#f4f1e8; --card:#fffdf7; --ink:#17201b; --muted:#667069; --line:#d9d4c6; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px; background:var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI",sans-serif; }
main { width:min(560px,100%); padding:40px; border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); }
h1 { margin:0 0 12px; font-size:clamp(32px,8vw,52px); letter-spacing:-.04em; }
p { margin:0; color:var(--muted); line-height:1.75; }
</style>
</head>
<body data-ycode-state="no-release"><main><h1>YCode</h1><p>当前还没有发布正式安装包。此静态页不加载脚本、不提供占位版本;发布完成后再刷新。</p></main></body>
</html>
`
}
export function usageError(usage, error) {
console.error(`${error.message}\n\n${usage}`)
process.exitCode = 2
}