/** * APS 桌面端离线升级模块(纯 Node,无 Electron 依赖,可在 node --test 下直接测试)。 * * 升级流程(applyUpdate): * ① 校验升级包(manifest 版本号 / sha256 / size / 平台) * ② 升级前备份当前安装目录(copy 或 listing 两种模式) * ③ 触发安装(默认 NSIS 静默 /S;可注入 install 函数便于测试) * ④ 安装后健康检查(GET /api/health,可选期望版本) * ⑤ 任一步失败 → 自动回滚(恢复备份)+ 审计记录(本地 JSONL 为主,可选上报 /api/gov/audit) * * 审计取舍说明: * - 本地日志文件({apsHome}/logs/upgrade-audit.jsonl,append-only)为默认审计通道; * - 上报 /api/gov/audit/events(round-45 HH,矩阵 103 收口)为默认尝试的旁路通道: * sidecar 启动时生成一次性审计 token(仅限本机、进程内,经 Electron main 注入 * auditApiUrl + auditToken + sidecarNonce);配置 auditApiUrl 即默认上报(带 Bearer * token + x-aps-sidecar-nonce,过 SidecarIdentityApp nonce 门禁),端点不存在/403/ * 网络错误 → fail-soft 记本地、不阻塞升级流程(保持 round-43 语义); * - web 模式该端点要求登录身份;desktop 模式由 nonce 门禁保护(见 docs/architecture/harness.md)。 * * 回滚模式说明: * - mode 'copy'(默认):整目录拷贝备份,可完整恢复文件内容; * - mode 'listing':仅记录文件清单 + 各文件 sha256(适合大目录/仅审计), * 此模式无法恢复内容,restoreBackup 会抛出 UpgradeError(需重装或改用 copy)。 */ const crypto = require('crypto'); const fs = require('fs'); const fsp = fs.promises; const http = require('http'); const path = require('path'); const { spawn } = require('child_process'); const LOOPBACK_HOST = '127.0.0.1'; class UpgradeError extends Error { /** * @param {string} code 机器可读错误码(CHECKSUM_MISMATCH / SIZE_MISMATCH / VERSION_NOT_NEWER / ...) * @param {string} message 人类可读描述 * @param {object} [details] 附加字段(期望值/实际值) */ constructor(code, message, details = {}) { super(message); this.name = 'UpgradeError'; this.code = code; this.details = details; } } /** 语义化版本比较:返回 -1 / 0 / 1(支持 主.次.补 及任意数字段) */ function compareVersions(a, b) { const parse = (v) => String(v).split('.').map((part) => { const n = Number.parseInt(part, 10); return Number.isNaN(n) ? 0 : n; }); const pa = parse(a); const pb = parse(b); const len = Math.max(pa.length, pb.length); for (let i = 0; i < len; i += 1) { const x = pa[i] ?? 0; const y = pb[i] ?? 0; if (x !== y) return x < y ? -1 : 1; } return 0; } /** 计算文件 SHA-256(小写十六进制) */ function sha256File(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(filePath); stream.on('data', (chunk) => hash.update(chunk)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', reject); }); } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } /** 读取并校验升级清单;返回规范化对象 */ function loadManifest(manifestPath) { let manifest; try { manifest = readJson(manifestPath); } catch (error) { throw new UpgradeError('MANIFEST_READ_FAILED', `升级清单读取失败: ${manifestPath}`, { cause: String(error) }); } const required = ['schemaVersion', 'product', 'version', 'package']; for (const key of required) { if (manifest[key] === undefined || manifest[key] === null || manifest[key] === '') { throw new UpgradeError('MANIFEST_INVALID', `升级清单缺少必需字段: ${key}`); } } const pkg = manifest.package; if (!pkg.fileName || typeof pkg.size !== 'number' || !/^[0-9a-f]{64}$/.test(pkg.sha256 || '')) { throw new UpgradeError('MANIFEST_INVALID', '升级清单 package 字段不完整(需 fileName/size/sha256[64hex])'); } return manifest; } /** * 校验升级包本身:存在性 + size + sha256 + 平台。 * @param {object} options * @param {object} options.manifest 已 loadManifest 的清单 * @param {string} options.packagePath 安装包绝对路径 * @param {string} [options.platform] 期望平台(默认 process.platform) */ async function verifyPackage({ manifest, packagePath, platform = process.platform }) { const pkg = manifest.package; let stat; try { stat = await fsp.stat(packagePath); } catch (error) { throw new UpgradeError('PACKAGE_NOT_FOUND', `升级安装包不存在: ${packagePath}`, { path: packagePath }); } if (!stat.isFile()) { throw new UpgradeError('PACKAGE_NOT_FILE', `升级安装包不是文件: ${packagePath}`); } if (manifest.platform && manifest.platform !== platform) { throw new UpgradeError('PLATFORM_MISMATCH', `升级包平台不匹配: 清单=${manifest.platform} 本机=${platform}`, { expected: manifest.platform, actual: platform, }); } if (stat.size !== pkg.size) { throw new UpgradeError('SIZE_MISMATCH', `升级包大小不匹配: 期望=${pkg.size} 实际=${stat.size}`, { expected: pkg.size, actual: stat.size, }); } const actualSha = await sha256File(packagePath); if (actualSha !== pkg.sha256) { throw new UpgradeError('CHECKSUM_MISMATCH', `升级包 SHA-256 校验失败: 期望=${pkg.sha256} 实际=${actualSha}`, { expected: pkg.sha256, actual: actualSha, }); } return { verified: true, size: stat.size, sha256: actualSha }; } /** * 校验版本:目标必须严格高于当前版本(拒绝回退/同版本)。 */ function ensureNewerVersion({ currentVersion, targetVersion }) { const cmp = compareVersions(targetVersion, currentVersion); if (cmp <= 0) { throw new UpgradeError( 'VERSION_NOT_NEWER', `拒绝升级: 目标版本 ${targetVersion} 不高于当前版本 ${currentVersion}`, { currentVersion, targetVersion }, ); } return true; } function copyDirRecursive(src, dest) { return fsp.cp(src, dest, { recursive: true, force: true }); } async function walkFiles(root, base = root, acc = []) { const entries = await fsp.readdir(root, { withFileTypes: true }); for (const entry of entries) { const absolute = path.join(root, entry.name); if (entry.isDirectory()) { await walkFiles(absolute, base, acc); } else { acc.push(path.relative(base, absolute)); } } return acc; } async function sha256OfTree(root) { const files = await walkFiles(root); const listing = []; for (const rel of files.sort()) { const absolute = path.join(root, rel); const stat = await fsp.stat(absolute); listing.push({ path: rel, size: stat.size, sha256: await sha256File(absolute) }); } return listing; } /** * 备份安装目录。 * @param {object} options * @param {string} options.installDir * @param {string} options.backupDir * @param {'copy'|'listing'} [options.mode] 默认 copy */ async function backupInstall({ installDir, backupDir, mode = 'copy' }) { const resolvedInstall = path.resolve(installDir); const resolvedBackup = path.resolve(backupDir); if (resolvedInstall === resolvedBackup) { throw new UpgradeError('BACKUP_SAME_DIR', '备份目录不能与安装目录相同'); } if (!fs.existsSync(resolvedInstall)) { throw new UpgradeError('INSTALL_DIR_MISSING', `安装目录不存在: ${resolvedInstall}`); } await fsp.rm(resolvedBackup, { recursive: true, force: true }); await fsp.mkdir(resolvedBackup, { recursive: true }); const listing = await sha256OfTree(resolvedInstall); if (mode === 'copy') { await copyDirRecursive(resolvedInstall, path.join(resolvedBackup, 'files')); } else if (mode !== 'listing') { throw new UpgradeError('BACKUP_MODE_INVALID', `未知备份模式: ${mode}`); } await fsp.writeFile( path.join(resolvedBackup, 'backup-manifest.json'), `${JSON.stringify({ mode, createdAt: new Date().toISOString(), files: listing }, null, 2)}\n`, 'utf8', ); return { mode, backupDir: resolvedBackup, fileCount: listing.length }; } /** * 从备份恢复安装目录(copy 模式可完整恢复;listing 模式仅能校验、不能恢复内容)。 */ async function restoreBackup({ backupDir, installDir, expectedMode = 'copy' }) { const resolvedBackup = path.resolve(backupDir); const resolvedInstall = path.resolve(installDir); if (resolvedInstall === resolvedBackup) { throw new UpgradeError('BACKUP_SAME_DIR', '备份目录不能与安装目录相同'); } const backupManifestPath = path.join(resolvedBackup, 'backup-manifest.json'); if (!fs.existsSync(backupManifestPath)) { throw new UpgradeError('BACKUP_MISSING', `备份不存在: ${backupManifestPath}`); } const backup = readJson(backupManifestPath); if (backup.mode !== expectedMode) { throw new UpgradeError( 'BACKUP_MODE_MISMATCH', `备份模式不匹配: 备份=${backup.mode} 期望=${expectedMode}(listing 模式无法恢复内容,需重装)`, { backupMode: backup.mode, expectedMode }, ); } if (backup.mode === 'listing') { throw new UpgradeError('BACKUP_LISTING_ONLY', 'listing 模式备份不含文件内容,无法自动回滚(请重装)'); } const filesSource = path.join(resolvedBackup, 'files'); if (!fs.existsSync(filesSource)) { throw new UpgradeError('BACKUP_FILES_MISSING', `备份文件缺失: ${filesSource}`); } // 清空安装目录后整体恢复;不做跨盘/越界检查之外的危险操作。 await fsp.rm(resolvedInstall, { recursive: true, force: true }); await fsp.mkdir(resolvedInstall, { recursive: true }); await copyDirRecursive(filesSource, resolvedInstall); return { restored: true, installDir: resolvedInstall, fileCount: backup.files.length }; } /** * 健康检查(round-44 FF):GET {base}/api/health,期望 HTTP 200。 * 版本比对:若 expectedVersion 且响应体含 version 字符串,则版本必须与 expectedVersion * 完全一致(不一致 → HEALTH_VERSION_MISMATCH);响应体无 version 字段(或非字符串)时 * 退化为仅 HTTP 200 检查,不因缺失版本而失败(兼容旧网关/开发环境)。 * nonce 续接(round-45 HH):sidecarNonce 提供时携带 x-aps-sidecar-nonce 头——升级后新 * sidecar 进程启用 nonce 门禁(缺失/不匹配 → 403),安装后健康检查必须用新网关的 nonce; * 403 且已携带 nonce 时错误 details.nonceMismatch=true,便于调用方区分“nonce 过期”与普通故障。 */ function probeHealth({ base, expectedVersion, timeoutMs = 5_000, sidecarNonce = null }) { const url = new URL('/api/health', base); return new Promise((resolve, reject) => { const headers = sidecarNonce ? { 'x-aps-sidecar-nonce': sidecarNonce } : {}; const request = http.get(url, { timeout: timeoutMs, headers }, (response) => { let body = ''; response.on('data', (chunk) => { body += chunk; }); response.on('end', () => { if (response.statusCode !== 200) { reject(new UpgradeError('HEALTH_HTTP_STATUS', `健康检查 HTTP ${response.statusCode}`, { url: url.href, statusCode: response.statusCode, nonceMismatch: response.statusCode === 403 && Boolean(sidecarNonce), })); return; } let parsed = null; try { parsed = JSON.parse(body); } catch { /* 非 JSON 仍视为健康 */ } const version = parsed && typeof parsed.version === 'string' ? parsed.version : null; if (expectedVersion && version && version !== expectedVersion) { reject(new UpgradeError('HEALTH_VERSION_MISMATCH', `健康检查版本不匹配: 期望=${expectedVersion} 实际=${version}`, { expectedVersion, actual: version, })); return; } resolve({ ok: true, statusCode: 200, version, interfaceVersion: parsed?.interfaceVersion ?? null, nonce: sidecarNonce, // 本次探测使用的 nonce(可追溯) }); }); }); request.once('timeout', () => request.destroy(new UpgradeError('HEALTH_TIMEOUT', '健康检查超时'))); request.once('error', (error) => reject(error instanceof UpgradeError ? error : new UpgradeError('HEALTH_UNREACHABLE', `健康检查失败: ${error.message}`))); }); } /** * NSIS 静默安装触发(默认安装器;测试注入 install 函数替代)。 * NSIS 参数:/S 静默;/D= 安装目录(须为最后参数、不带引号)。 */ function nsisInstall({ installerPath, installDir }) { return new Promise((resolve, reject) => { const args = ['/S']; if (installDir) args.push(`/D=${installDir}`); const child = spawn(installerPath, args, { windowsHide: true, stdio: 'ignore', detached: false, }); const timer = setTimeout(() => { child.kill(); reject(new UpgradeError('INSTALL_TIMEOUT', '安装器执行超时')); }, 10 * 60 * 1000); child.once('error', (error) => { clearTimeout(timer); reject(new UpgradeError('INSTALL_SPAWN_FAILED', `安装器启动失败: ${error.message}`)); }); child.once('exit', (code) => { clearTimeout(timer); if (code === 0) resolve({ exitCode: 0 }); else reject(new UpgradeError('INSTALL_EXIT_NONZERO', `安装器退出码非零: ${code}`, { exitCode: code })); }); }); } class Updater { /** * @param {object} options * @param {string} [options.apsHome] 用户目录(审计日志写 {apsHome}/logs/upgrade-audit.jsonl) * @param {object} [options.logger] console 兼容 logger * @param {string} [options.auditApiUrl] 可选:上报 /api/gov/audit 的 base(如 sidecar origin) * @param {string} [options.auditToken] 可选:sidecar 一次性审计 token(Bearer 携带) * @param {string} [options.sidecarNonce] 可选:sidecar 进程 nonce(x-aps-sidecar-nonce 头,过网关身份门禁) */ constructor({ apsHome, logger = console, auditApiUrl = null, auditToken = null, sidecarNonce = null } = {}) { this.apsHome = apsHome || path.join(require('os').homedir(), '.aps'); this.logger = logger; this.auditApiUrl = auditApiUrl; this.auditToken = auditToken; this.sidecarNonce = sidecarNonce; } /** * ① 检查更新:读取清单 + 校验安装包 + 版本比较。 * @returns {Promise<{updateAvailable:boolean,currentVersion:string,targetVersion:string,manifest:object,verified:boolean}>} */ async checkForUpdate({ manifestPath, packagePath, currentVersion, platform }) { const manifest = loadManifest(manifestPath); let verified = false; let verifyError = null; try { if (packagePath) { await verifyPackage({ manifest, packagePath, platform }); verified = true; } } catch (error) { verifyError = error instanceof UpgradeError ? error : new UpgradeError('VERIFY_FAILED', String(error)); } const updateAvailable = (() => { try { ensureNewerVersion({ currentVersion, targetVersion: manifest.version }); return true; } catch { return false; } })(); return { updateAvailable, currentVersion, targetVersion: manifest.version, manifest, verified, ...(verifyError ? { verifyError: { code: verifyError.code, message: verifyError.message } } : {}), }; } /** 写本地审计 JSONL(append-only);配置 auditApiUrl 即默认尝试上报 /api/gov/audit/events。 */ async writeAudit({ event, category = 'UPGRADE', result = 'SUCCESS', payload = {} }) { const entry = { ts: new Date().toISOString(), event, category, result, payload, }; const logsDir = path.join(this.apsHome, 'logs'); await fsp.mkdir(logsDir, { recursive: true }); const auditFile = path.join(logsDir, 'upgrade-audit.jsonl'); await fsp.appendFile(auditFile, `${JSON.stringify(entry)}\n`, 'utf8'); // round-45 HH:配置 auditApiUrl 即默认尝试上报(带 nonce+token);端点不存在/403/网络 // 错误一律 fail-soft——本地 JSONL 是主通道,上报失败不抛错、不阻塞升级(保持 round-43 语义)。 if (this.auditApiUrl) { await this.reportAuditToApi(entry).catch((error) => { this.logger.warn?.('[updater] 审计上报失败(仅本地记录):', error.message); }); } return entry; } /** 上报 /api/gov/audit/events(round-45 HH:端点不存在/403 → 抛错,由 writeAudit fail-soft 兜底)。 * payload 转换:本地 entry {ts,event,category,result,payload} → 服务端审计链契约 * {category,action,power,actor,rationale,target,timestamp,result,idempotencyKey}; * idempotencyKey = sha256(event+category+result+ts+payload) 的确定性指纹——同一 entry * 重试(如响应丢失)会被服务端幂等去重,不重复落链。 */ async reportAuditToApi(entry) { const url = new URL('/api/gov/audit/events', this.auditApiUrl); const headers = { 'content-type': 'application/json', }; if (this.auditToken) headers.authorization = `Bearer ${this.auditToken}`; if (this.sidecarNonce) headers['x-aps-sidecar-nonce'] = this.sidecarNonce; const idempotencyKey = crypto .createHash('sha256') .update(JSON.stringify([entry.event, entry.category, entry.result, entry.ts, entry.payload ?? {}])) .digest('hex'); const body = { category: entry.category || 'UPGRADE', action: entry.event, power: 'P0', actor: 'desktop-updater', target: { type: 'UPGRADE' }, rationale: { source: 'desktop-updater', payload: entry.payload ?? {} }, timestamp: entry.ts, result: entry.result || 'SUCCESS', idempotencyKey, }; const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(3_000), }); if (!response.ok) { throw new UpgradeError('AUDIT_API_HTTP', `审计上报 HTTP ${response.status}`, { status: response.status }); } } /** * 完整升级流程(含失败自动回滚)。 * @param {object} options * @param {string} options.manifestPath 升级清单路径 * @param {string} options.packagePath 安装包路径 * @param {string} options.installDir 安装目录 * @param {string} options.backupDir 备份目录 * @param {string} options.currentVersion 当前版本 * @param {string} [options.healthBase] 健康检查 base URL(默认 http://127.0.0.1:8000) * @param {string} [options.expectedVersion] 健康检查期望版本(可选) * @param {string} [options.sidecarNonce] 安装后健康检查用的 nonce(round-45 HH:升级后新 * sidecar 进程启用 nonce 门禁,探测必须带新网关的 nonce;缺省回落到构造时的 * this.sidecarNonce。新 nonce 由调用方(Electron main)在 sidecar 重启后经 SidecarManager * 重新生成并传入,或在升级完成后由 main 更新 nonce 后再做最终健康检查) * @param {'copy'|'listing'} [options.backupMode] * @param {Function} [options.install] 安装函数(默认 nsisInstall);测试注入 * @param {boolean} [options.skipHealthCheck] 测试/无 sidecar 场景可跳过 * @returns {Promise<{ok:boolean,manifest:object,backup:object,health?:object}>} */ async applyUpdate(options) { const { manifestPath, packagePath, installDir, backupDir, currentVersion, healthBase = `http://${LOOPBACK_HOST}:8000`, expectedVersion = null, backupMode = 'copy', install = nsisInstall, skipHealthCheck = false, sidecarNonce = this.sidecarNonce, } = options; const manifest = loadManifest(manifestPath); const report = { ok: false, manifest, currentVersion, targetVersion: manifest.version, nonce: sidecarNonce ?? null, // round-45 HH:本次流程使用的 sidecar nonce(供调用方续接) }; try { // ① 校验 this.logger.info?.('[updater] 校验升级包…'); await verifyPackage({ manifest, packagePath }); ensureNewerVersion({ currentVersion, targetVersion: manifest.version }); await this.writeAudit({ event: 'upgrade.verified', payload: { fromVersion: currentVersion, toVersion: manifest.version, fileName: manifest.package.fileName }, }); // ② 备份 this.logger.info?.('[updater] 备份当前安装目录…'); const backup = await backupInstall({ installDir, backupDir, mode: backupMode }); report.backup = backup; await this.writeAudit({ event: 'upgrade.backup', payload: backup }); // ③ 安装 this.logger.info?.('[updater] 执行安装…'); await install({ installerPath: packagePath, installDir, manifest }); await this.writeAudit({ event: 'upgrade.installed', payload: { toVersion: manifest.version } }); // ④ 健康检查 let health = null; if (!skipHealthCheck) { this.logger.info?.('[updater] 安装后健康检查…'); health = await probeHealth({ base: healthBase, expectedVersion, sidecarNonce }); report.health = health; await this.writeAudit({ event: 'upgrade.health-check', payload: health }); } report.ok = true; await this.writeAudit({ event: 'upgrade.succeeded', payload: { fromVersion: currentVersion, toVersion: manifest.version } }); return report; } catch (error) { // ⑤ 失败自动回滚(round-46:升级途中 sidecar 重启导致的 nonce 轮换除外—— // 安装已成功,健康检查 403 仅因 nonce 过期 → 返回 pending 语义,由调用方 // (Electron main 的 upgrade-nonce 协调器)以新 nonce 补验,不误触发回滚) const reason = error instanceof UpgradeError ? error.code : 'UNKNOWN'; const isNonceMismatch = error instanceof UpgradeError && error.details && error.details.nonceMismatch === true; if (isNonceMismatch) { await this.writeAudit({ event: 'upgrade.health-pending', result: 'PENDING', payload: { reason: 'NONCE_MISMATCH', message: error.message }, }).catch(() => {}); this.logger.warn?.('[updater] 健康检查 nonce 已轮换(sidecar 重启),跳过回滚,交由调用方以新 nonce 补验'); report.ok = false; report.deferredVerification = true; report.reason = 'NONCE_MISMATCH'; return report; } await this.writeAudit({ event: 'upgrade.failed', result: 'FAILED', payload: { reason, message: error.message } }).catch(() => {}); this.logger.error?.('[updater] 升级失败,自动回滚:', error.message); const rollback = await this.rollback({ backupDir, installDir, reason, backupMode, }).catch((rollbackError) => { this.logger.error?.('[updater] 回滚失败:', rollbackError.message); return { rollbackFailed: true, error: rollbackError.message }; }); report.rollback = rollback; throw error; } } /** * 手动/自动回滚:恢复备份 + 审计。 */ async rollback({ backupDir, installDir, reason = 'manual', backupMode = 'copy' }) { const restored = await restoreBackup({ backupDir, installDir, expectedMode: backupMode }); await this.writeAudit({ event: 'rollback.done', result: 'SUCCESS', payload: { reason, ...restored } }); return restored; } /** 供测试/外部直接调用备份 */ backupInstall(options) { return backupInstall(options); } } module.exports = { UpgradeError, Updater, compareVersions, sha256File, loadManifest, verifyPackage, ensureNewerVersion, backupInstall, restoreBackup, probeHealth, nsisInstall, LOOPBACK_HOST, };