243 lines
8.6 KiB
JavaScript
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");
|
|
}
|