From 0a7ad6a3b6e956c6ad367c80055a086587405a54 Mon Sep 17 00:00:00 2001 From: Donald Date: Wed, 19 Aug 2026 02:09:59 +0000 Subject: [PATCH] fix: honor proxies in runtime source verification --- docs/release-checklist.md | 2 +- scripts/verify-node-runtime-sources.mjs | 41 +++++++++++++++++++------ test/distribution.test.ts | 5 +++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 7dd6c26..73fb523 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -3,7 +3,7 @@ ## 每个候选版必须完成 - 当前提交位于 `main`,工作区除明确忽略的本地缓存外干净;版本号、插件版本和 Git 标签一致。 -- `npm ci`、`npm test`、`npm audit --omit=dev --audit-level=high` 全部通过。 +- `npm ci`、`npm test`、`npm audit --omit=dev --audit-level=high` 全部通过。发布机存在代理环境变量时,运行时来源校验应优先使用系统 `curl` 继承代理,并在短超时内给出明确失败;不要依赖默认不读取代理的 Node `fetch`。 - `npm run runtime:verify` 确认 Node 官方精确版本源和至少一个国内源同时提供全部六个平台制品,且镜像 `SHASUMS256.txt` 与仓库固定哈希一致。 - `release:build`、`test-bundle:build` 和 `release:verify` 成功,`SHA256SUMS` 校验通过,发行物不包含 `.secrets`、`.runtime`、数据库、私钥或机器私有路径。 - 国内 Gitea 首选源与自建 HTTPS 备用目录的 `manifest.json`、`manifest.sig` 和全部安装附件逐文件哈希一致;黑盒测试包及动态提示词只要求自建目录可用。连续部署同一 Tag 必须复用既有 Release 和固定 `createdAt`,不得重复创建或因测试包时间戳产生哈希漂移。模拟首选源网络失败/404 后能从备用源完成首次安装和更新,模拟首选源返回错误哈希或签名时必须立即停止而不换源。 diff --git a/scripts/verify-node-runtime-sources.mjs b/scripts/verify-node-runtime-sources.mjs index 51155eb..0526d6f 100644 --- a/scripts/verify-node-runtime-sources.mjs +++ b/scripts/verify-node-runtime-sources.mjs @@ -1,8 +1,13 @@ #!/usr/bin/env node +import { execFile as execFileCallback, spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { promisify } from "node:util"; const catalog = JSON.parse(readFileSync(new URL("../distribution/node-runtime.json", import.meta.url), "utf8")); const expected = new Map(Object.values(catalog.platforms).map((value) => [value.file, value.sha256])); +const execFile = promisify(execFileCallback); +const curlAvailable = spawnSync("curl", ["--version"], { stdio: "ignore" }).status === 0; +const nullDevice = process.platform === "win32" ? "NUL" : "/dev/null"; const results = await Promise.all(catalog.sourceBases.map(checkSource)); for (const result of results) { console.log(`${result.ok ? "OK" : "FAIL"} ${result.host}: ${result.message}`); @@ -18,18 +23,11 @@ async function checkSource(sourceBase) { const timeout = setTimeout(() => controller.abort(), 20_000); timeout.unref(); try { - const checksumResponse = await fetch(new URL("SHASUMS256.txt", base), { signal: controller.signal }); - if (!checksumResponse.ok) return failure(base, `SHASUMS256.txt HTTP ${checksumResponse.status}`); - const checksums = parseChecksums(await checksumResponse.text()); + const checksums = parseChecksums(await requestText(new URL("SHASUMS256.txt", base), controller.signal)); for (const [file, sha256] of expected) { if (checksums.get(file) !== sha256) return failure(base, `${file} 的官方 SHA-256 不匹配或缺失`); } - const heads = await Promise.all([...expected.keys()].map(async (file) => { - const response = await fetch(new URL(file, base), { method: "HEAD", signal: controller.signal }); - return { file, status: response.status, ok: response.ok }; - })); - const unavailable = heads.filter((item) => !item.ok); - if (unavailable.length) return failure(base, `${unavailable[0].file} HTTP ${unavailable[0].status}`); + await Promise.all([...expected.keys()].map((file) => assertAvailable(new URL(file, base), controller.signal))); return { host: base.host, ok: true, message: `${expected.size} 个三平台制品可用且哈希一致` }; } catch (error) { return failure(base, error instanceof Error ? error.message : String(error)); @@ -38,6 +36,31 @@ async function checkSource(sourceBase) { } } +async function requestText(url, signal) { + if (!curlAvailable) { + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error(`${url.pathname} HTTP ${response.status}`); + return response.text(); + } + const { stdout } = await execFile("curl", [ + "--fail", "--silent", "--show-error", "--location", + "--connect-timeout", "10", "--max-time", "20", url.toString(), + ], { encoding: "utf8", maxBuffer: 2 * 1024 * 1024, signal }); + return stdout; +} + +async function assertAvailable(url, signal) { + if (!curlAvailable) { + const response = await fetch(url, { method: "HEAD", signal }); + if (!response.ok) throw new Error(`${url.pathname} HTTP ${response.status}`); + return; + } + await execFile("curl", [ + "--fail", "--silent", "--show-error", "--location", "--head", + "--connect-timeout", "10", "--max-time", "20", "--output", nullDevice, url.toString(), + ], { encoding: "utf8", maxBuffer: 256 * 1024, signal }); +} + function parseChecksums(body) { const values = new Map(); for (const line of body.split(/\r?\n/)) { diff --git a/test/distribution.test.ts b/test/distribution.test.ts index a9e6e1d..917c20c 100644 --- a/test/distribution.test.ts +++ b/test/distribution.test.ts @@ -50,6 +50,11 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati assert.ok(runtime.sourceBases.every((value: string) => value.includes(`/v${runtime.version}/`) && !value.includes("latest"))); assert.ok(Object.values(runtime.platforms).every((value) => /^[a-f0-9]{64}$/.test((value as { sha256: string }).sha256))); + const runtimeVerifier = readFileSync("scripts/verify-node-runtime-sources.mjs", "utf8"); + assert.match(runtimeVerifier, /spawnSync\("curl"[\s\S]*curlAvailable/); + assert.match(runtimeVerifier, /--connect-timeout", "10"[\s\S]*--max-time", "20"/); + assert.match(runtimeVerifier, /if \(!curlAvailable\)[\s\S]*fetch\(/); + const windowsBootstrap = readFileSync("distribution/bootstrap-windows.template.ps1", "utf8"); const posixBootstrap = readFileSync("distribution/bootstrap-posix.template.sh", "utf8"); assert.match(windowsBootstrap, /OSArchitecture[\s\S]*Save-DingDownload[\s\S]*Expand-Archive/);