import { createHash } from "node:crypto"; import { DAEMON_RELEASE_POLICY } from "./daemon-policy.mjs"; const CHECKSUMS_FILE = "checksums.txt"; const API_MAX_BYTES = 2 * 1024 * 1024; const CHECKSUMS_MAX_BYTES = 64 * 1024; const ASSET_MAX_BYTES = 128 * 1024 * 1024; export class ReleaseVerificationError extends Error { constructor(code, message) { super(message); this.name = "ReleaseVerificationError"; this.code = code; } } function fail(code, message) { throw new ReleaseVerificationError(code, message); } function versionTuple(value) { const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value); if (!match) return null; const parts = match.slice(1).map(Number); return parts.every(Number.isSafeInteger) ? parts : null; } function compareVersions(left, right) { for (let index = 0; index < 3; index += 1) { if (left[index] !== right[index]) return left[index] - right[index]; } return 0; } export function parseReleaseTag(rawTag) { const tag = String(rawTag ?? "").trim(); const match = /^v(\d+\.\d+\.\d+)$/.exec(tag); if (!match) fail("invalid_release_tag", "tag 必须是稳定版本 vX.Y.Z"); const version = match[1]; const current = versionTuple(version); const minimum = versionTuple(DAEMON_RELEASE_POLICY.minimumVersion); if (!current || !minimum || compareVersions(current, minimum) < 0) { fail( "incompatible_release_version", `Cloud daemon 版本不得低于 v${DAEMON_RELEASE_POLICY.minimumVersion}`, ); } return { tag, version }; } function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } function githubHeaders(token, accept = "application/vnd.github+json") { return { accept, "user-agent": "nekonest-cloud-release-verifier", "x-github-api-version": "2022-11-28", ...(token ? { authorization: `Bearer ${token}` } : {}), }; } async function fetchBytes(fetchImpl, url, options) { let response; try { response = await fetchImpl(url, { headers: githubHeaders(options.authenticated ? options.token : undefined, options.accept), redirect: "follow", signal: AbortSignal.timeout(30_000), }); } catch { fail("release_fetch_failed", `${options.label} 请求失败`); } if (!response?.ok) { fail("release_fetch_rejected", `${options.label} 返回 HTTP ${response?.status ?? "unknown"}`); } const declared = response.headers.get("content-length"); if (declared && (!/^\d+$/.test(declared) || Number(declared) > options.maxBytes)) { fail("release_response_too_large", `${options.label} 超过大小上限`); } let bytes; try { bytes = new Uint8Array(await response.arrayBuffer()); } catch { fail("release_response_read_failed", `${options.label} 响应读取失败`); } if (bytes.byteLength > options.maxBytes) { fail("release_response_too_large", `${options.label} 超过大小上限`); } return bytes; } function exactDownloadUrl(tag, filename) { return `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/download/${tag}/${filename}`; } function validateAsset(record, tag, filename) { if (!record || typeof record !== "object") fail("release_asset_missing", `缺少 ${filename}`); if (record.name !== filename || record.browser_download_url !== exactDownloadUrl(tag, filename)) { fail("release_asset_url_mismatch", `${filename} 不是精确 tag 的官方资产`); } if ( record.state !== "uploaded" || !Number.isSafeInteger(record.size) || record.size < 1 || record.size > ASSET_MAX_BYTES ) { fail("release_asset_size_invalid", `${filename} 的资产大小无效`); } const digest = String(record.digest ?? "").toLowerCase(); if (!/^sha256:[0-9a-f]{64}$/.test(digest)) { fail("release_asset_digest_missing", `${filename} 缺少 GitHub SHA-256 digest`); } return { ...record, digest: digest.slice("sha256:".length) }; } function indexRequiredAssets(release, tag) { if (!Array.isArray(release.assets)) fail("release_assets_invalid", "Release assets 不是数组"); const requiredNames = [ ...DAEMON_RELEASE_POLICY.assets.map((asset) => asset.filename), CHECKSUMS_FILE, ]; const indexed = new Map(); for (const name of requiredNames) { const matches = release.assets.filter((asset) => asset?.name === name); if (matches.length !== 1) { fail( matches.length === 0 ? "release_asset_missing" : "release_asset_duplicate", `${name} 必须恰好出现一次`, ); } indexed.set(name, validateAsset(matches[0], tag, name)); } return indexed; } function parseChecksums(bytes) { const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); const checksums = new Map(); for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line) continue; const match = /^([0-9a-fA-F]{64})\s+\*?([^\s]+)$/.exec(line); if (!match) fail("checksums_format_invalid", "checksums.txt 包含无效行"); const filename = match[2]; if (checksums.has(filename)) fail("checksums_duplicate", `${filename} 摘要重复`); checksums.set(filename, match[1].toLowerCase()); } return checksums; } export async function verifyDaemonRelease(input) { const { tag, version } = parseReleaseTag(input.tag); const fetchImpl = input.fetchImpl ?? globalThis.fetch; if (typeof fetchImpl !== "function") fail("fetch_unavailable", "当前 Node 不支持 fetch"); const apiUrl = `https://api.github.com/repos/${DAEMON_RELEASE_POLICY.repository}/releases/tags/${tag}`; const apiBytes = await fetchBytes(fetchImpl, apiUrl, { token: input.token, authenticated: true, label: "GitHub Release API", maxBytes: API_MAX_BYTES, }); let release; try { release = JSON.parse(new TextDecoder().decode(apiBytes)); } catch { fail("release_json_invalid", "GitHub Release API 返回了无效 JSON"); } const publishedAt = new Date(release?.published_at ?? ""); if ( release?.tag_name !== tag || release?.draft !== false || release?.prerelease !== false || release?.html_url !== `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/tag/${tag}` || !Number.isFinite(publishedAt.getTime()) ) { fail("release_identity_invalid", "Release 必须是精确 tag 的公开稳定版本"); } const assetsByName = indexRequiredAssets(release, tag); const checksumAsset = assetsByName.get(CHECKSUMS_FILE); const checksumsBytes = await fetchBytes(fetchImpl, checksumAsset.browser_download_url, { token: input.token, label: CHECKSUMS_FILE, accept: "application/octet-stream", maxBytes: CHECKSUMS_MAX_BYTES, }); if (checksumsBytes.byteLength !== checksumAsset.size || sha256(checksumsBytes) !== checksumAsset.digest) { fail("checksums_asset_mismatch", "checksums.txt 大小或 GitHub digest 不匹配"); } const checksums = parseChecksums(checksumsBytes); const verifiedAssets = []; for (const policyAsset of DAEMON_RELEASE_POLICY.assets) { const releaseAsset = assetsByName.get(policyAsset.filename); const expected = checksums.get(policyAsset.filename); if (!expected) fail("checksums_entry_missing", `${policyAsset.filename} 没有摘要`); if (releaseAsset.digest !== expected) { fail("release_digest_mismatch", `${policyAsset.filename} 的 API digest 与清单不一致`); } const bytes = await fetchBytes(fetchImpl, releaseAsset.browser_download_url, { token: input.token, label: policyAsset.filename, accept: "application/octet-stream", maxBytes: ASSET_MAX_BYTES, }); if (bytes.byteLength !== releaseAsset.size) { fail("release_asset_size_mismatch", `${policyAsset.filename} 实际大小不匹配`); } if (sha256(bytes) !== expected) { fail("release_asset_hash_mismatch", `${policyAsset.filename} 实际 SHA-256 不匹配`); } verifiedAssets.push({ ...policyAsset, sha256: expected, size: bytes.byteLength, downloadUrl: releaseAsset.browser_download_url, }); } return { repository: DAEMON_RELEASE_POLICY.repository, tag, version, releasePageUrl: release.html_url, releaseBaseUrl: `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/download/${tag}`, checksumsUrl: checksumAsset.browser_download_url, checksumsSha256: checksumAsset.digest, publishedAt: publishedAt.toISOString(), assets: verifiedAssets, }; } export function formatCloudReleaseEnvironment(verified) { const lines = [ `NEKONEST_CLOUD_DAEMON_RELEASE_VERSION=${verified.version}`, `NEKONEST_CLOUD_DAEMON_RELEASE_BASE_URL=${verified.releaseBaseUrl}`, ]; for (const asset of verified.assets) lines.push(`${asset.checksumEnv}=${asset.sha256}`); return lines.join("\n"); }