Files
nekonest-cloud/release/verify-daemon-release-core.mjs
T

243 lines
8.6 KiB
JavaScript

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");
}