177 lines
6.3 KiB
JavaScript
177 lines
6.3 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import test from "node:test";
|
|
import { DAEMON_RELEASE_POLICY } from "../release/daemon-policy.mjs";
|
|
import {
|
|
ReleaseVerificationError,
|
|
formatCloudReleaseEnvironment,
|
|
parseReleaseTag,
|
|
verifyDaemonRelease,
|
|
} from "../release/verify-daemon-release-core.mjs";
|
|
|
|
const TAG = "v0.2.6";
|
|
const API_URL = `https://api.github.com/repos/${DAEMON_RELEASE_POLICY.repository}/releases/tags/${TAG}`;
|
|
const RELEASE_BASE = `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/download/${TAG}`;
|
|
|
|
function hash(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function fixture() {
|
|
const bodies = new Map(
|
|
DAEMON_RELEASE_POLICY.assets.map((asset, index) => [
|
|
asset.filename,
|
|
Buffer.from(`verified-daemon-asset-${index + 1}`),
|
|
]),
|
|
);
|
|
const checksumsText = [
|
|
...DAEMON_RELEASE_POLICY.assets.map(
|
|
(asset) => `${hash(bodies.get(asset.filename))} ${asset.filename}`,
|
|
),
|
|
`${"9".repeat(64)} nekonest-server-linux-amd64.tar.gz`,
|
|
].join("\n") + "\n";
|
|
const checksumsBytes = Buffer.from(checksumsText);
|
|
const release = {
|
|
tag_name: TAG,
|
|
draft: false,
|
|
prerelease: false,
|
|
html_url: `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/tag/${TAG}`,
|
|
published_at: "2026-08-12T00:00:00Z",
|
|
assets: [
|
|
...DAEMON_RELEASE_POLICY.assets.map((asset) => ({
|
|
name: asset.filename,
|
|
browser_download_url: `${RELEASE_BASE}/${asset.filename}`,
|
|
size: bodies.get(asset.filename).byteLength,
|
|
state: "uploaded",
|
|
digest: `sha256:${hash(bodies.get(asset.filename))}`,
|
|
})),
|
|
{
|
|
name: "checksums.txt",
|
|
browser_download_url: `${RELEASE_BASE}/checksums.txt`,
|
|
size: checksumsBytes.byteLength,
|
|
state: "uploaded",
|
|
digest: `sha256:${hash(checksumsBytes)}`,
|
|
},
|
|
],
|
|
};
|
|
const requests = [];
|
|
const fetchImpl = async (url, init) => {
|
|
requests.push({ url: String(url), init });
|
|
let bytes;
|
|
if (String(url) === API_URL) bytes = Buffer.from(JSON.stringify(release));
|
|
else if (String(url) === `${RELEASE_BASE}/checksums.txt`) bytes = checksumsBytes;
|
|
else {
|
|
const filename = String(url).slice(RELEASE_BASE.length + 1);
|
|
bytes = bodies.get(filename);
|
|
}
|
|
if (!bytes) return new Response("missing", { status: 404 });
|
|
return new Response(bytes, {
|
|
status: 200,
|
|
headers: { "content-length": String(bytes.byteLength) },
|
|
});
|
|
};
|
|
return { bodies, checksumsBytes, fetchImpl, release, requests };
|
|
}
|
|
|
|
async function expectCode(promise, code) {
|
|
await assert.rejects(promise, (error) => {
|
|
assert.ok(error instanceof ReleaseVerificationError);
|
|
assert.equal(error.code, code);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
test("rejects old or prerelease tags before making a network request", async () => {
|
|
let calls = 0;
|
|
const fetchImpl = async () => {
|
|
calls += 1;
|
|
throw new Error("should not fetch");
|
|
};
|
|
assert.throws(() => parseReleaseTag("v0.2.5"), /不得低于 v0\.2\.6/);
|
|
assert.throws(() => parseReleaseTag("v0.2.6-beta.1"), /稳定版本/);
|
|
await expectCode(
|
|
verifyDaemonRelease({ tag: "v0.2.5", fetchImpl }),
|
|
"incompatible_release_version",
|
|
);
|
|
assert.equal(calls, 0);
|
|
});
|
|
|
|
test("verifies API identity, checksums digest, asset digests, sizes, and downloaded bytes", async () => {
|
|
const data = fixture();
|
|
const token = "github-test-token-that-must-not-be-returned";
|
|
const verified = await verifyDaemonRelease({ tag: TAG, token, fetchImpl: data.fetchImpl });
|
|
assert.equal(verified.version, "0.2.6");
|
|
assert.equal(verified.assets.length, 3);
|
|
assert.equal(verified.assets[0].sha256, hash(data.bodies.get(verified.assets[0].filename)));
|
|
assert.equal(data.requests.length, 5);
|
|
assert.equal(data.requests[0].init.headers.authorization, `Bearer ${token}`);
|
|
for (const request of data.requests.slice(1)) {
|
|
assert.equal(request.init.headers.authorization, undefined);
|
|
}
|
|
const serialized = JSON.stringify(verified);
|
|
assert.doesNotMatch(serialized, /github-test-token/);
|
|
const environment = formatCloudReleaseEnvironment(verified);
|
|
assert.match(environment, /NEKONEST_CLOUD_DAEMON_RELEASE_VERSION=0\.2\.6/);
|
|
assert.match(environment, /NEKONEST_CLOUD_DAEMON_LINUX_ARM64_SHA256=[0-9a-f]{64}/);
|
|
assert.doesNotMatch(environment, /GITHUB_TOKEN|github-test-token/);
|
|
});
|
|
|
|
test("fails closed on duplicate assets, URL substitution, prerelease metadata, or digest drift", async () => {
|
|
{
|
|
const data = fixture();
|
|
data.release.assets.push({ ...data.release.assets[0] });
|
|
await expectCode(
|
|
verifyDaemonRelease({ tag: TAG, fetchImpl: data.fetchImpl }),
|
|
"release_asset_duplicate",
|
|
);
|
|
}
|
|
{
|
|
const data = fixture();
|
|
data.release.assets[0].browser_download_url = "https://download.example.test/substituted.zip";
|
|
await expectCode(
|
|
verifyDaemonRelease({ tag: TAG, fetchImpl: data.fetchImpl }),
|
|
"release_asset_url_mismatch",
|
|
);
|
|
}
|
|
{
|
|
const data = fixture();
|
|
data.release.prerelease = true;
|
|
await expectCode(
|
|
verifyDaemonRelease({ tag: TAG, fetchImpl: data.fetchImpl }),
|
|
"release_identity_invalid",
|
|
);
|
|
}
|
|
{
|
|
const data = fixture();
|
|
data.release.assets[0].digest = `sha256:${"f".repeat(64)}`;
|
|
await expectCode(
|
|
verifyDaemonRelease({ tag: TAG, fetchImpl: data.fetchImpl }),
|
|
"release_digest_mismatch",
|
|
);
|
|
}
|
|
});
|
|
|
|
test("detects same-sized downloaded byte substitution even when metadata still looks valid", async () => {
|
|
const data = fixture();
|
|
const filename = DAEMON_RELEASE_POLICY.assets[0].filename;
|
|
const original = data.bodies.get(filename);
|
|
data.bodies.set(filename, Buffer.from(original.toString().replace("verified", "VERIFIED")));
|
|
assert.equal(data.bodies.get(filename).byteLength, original.byteLength);
|
|
await expectCode(
|
|
verifyDaemonRelease({ tag: TAG, fetchImpl: data.fetchImpl }),
|
|
"release_asset_hash_mismatch",
|
|
);
|
|
});
|
|
|
|
test("CLI has a no-network usage failure when the exact tag is missing", () => {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
["tools/verify-daemon-release.mjs"],
|
|
{ cwd: new URL("..", import.meta.url), encoding: "utf8" },
|
|
);
|
|
assert.equal(result.status, 2);
|
|
assert.match(result.stderr, /vX\.Y\.Z/);
|
|
assert.equal(result.stdout, "");
|
|
});
|