Files
ctms/frontend/scripts/verify-desktop-update-feed.mjs
T
Cheng Zhou c23a5d6a22
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
build(desktop): allow controlled unsigned v0.1.0 release
2026-07-17 10:52:20 +08:00

236 lines
9.6 KiB
JavaScript

import { access, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = [];
const args = process.argv.slice(2);
const optionValue = (name) => {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
};
const optionValues = (name) =>
args.reduce((result, argument, index) => (argument === name && args[index + 1] ? [...result, args[index + 1]] : result), []);
const feedPath = resolve(
frontendDir,
optionValue("--feed") || process.env.DESKTOP_UPDATE_FEED || "src-tauri/target/release/bundle/latest.json",
);
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const requiresProvenance = args.includes("--require-provenance");
const provenancePath =
optionValue("--provenance") ||
process.env.DESKTOP_RELEASE_PROVENANCE ||
(artifactDir ? resolve(artifactDir, "DESKTOP-RELEASE-PROVENANCE.json") : undefined);
const requiredPlatforms = new Set([
"darwin-aarch64",
"darwin-x86_64",
...optionValues("--require-platform"),
]);
const platformPattern = /^(?:darwin|windows|linux)-(?:x86_64|aarch64|i686|armv7)$/;
const checksumManifestPath =
optionValue("--checksum-manifest") ||
process.env.DESKTOP_UPDATE_CHECKSUM_MANIFEST ||
(artifactDir ? resolve(artifactDir, "SHA256SUMS.txt") : undefined);
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const assertFileExists = async (path, description) => {
try {
await access(path);
} catch {
fail(`${description} does not exist: ${path}`);
}
};
const sha256 = async (path) => createHash("sha256").update(await readFile(path)).digest("hex");
const readChecksumManifest = async () => {
if (!checksumManifestPath) return undefined;
try {
const source = await readFile(checksumManifestPath, "utf8");
const checksums = new Map();
for (const line of source.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i);
if (!match) {
fail(`Checksum manifest contains an invalid line: ${line}`);
continue;
}
checksums.set(basename(match[2]), match[1].toLowerCase());
}
return checksums;
} catch (error) {
fail(`Cannot read checksum manifest ${checksumManifestPath}: ${error.message}`);
return undefined;
}
};
const assertChecksum = async (checksums, path, description) => {
if (!checksums) return;
const name = basename(path);
const expected = checksums.get(name);
assert(Boolean(expected), `Checksum manifest must include ${description}: ${name}`);
if (expected) {
const actual = await sha256(path);
assert(actual === expected, `${description} checksum mismatch for ${name}.`);
}
};
const assertManifestEntries = async (checksums) => {
if (!checksums || !artifactDir) return;
for (const [name, expected] of checksums.entries()) {
const path = resolve(artifactDir, name);
await assertFileExists(path, `checksum manifest entry ${name}`);
try {
const actual = await sha256(path);
assert(actual === expected, `Checksum manifest entry mismatch for ${name}.`);
} catch (error) {
fail(`Cannot verify checksum manifest entry ${name}: ${error.message}`);
}
}
};
const verifyProvenance = async (checksums) => {
if (!requiresProvenance) return;
if (!provenancePath) {
fail("Desktop release provenance path is required.");
return;
}
let provenance;
try {
provenance = JSON.parse(await readFile(provenancePath, "utf8"));
} catch (error) {
fail(`Cannot read desktop release provenance ${provenancePath}: ${error.message}`);
return;
}
assert(provenance.schemaVersion === 1, "Desktop release provenance schemaVersion must be 1.");
assert(provenance.version === packageInfo.version, "Desktop release provenance version must match the package version.");
assert(provenance.tag === `v${packageInfo.version}`, "Desktop release provenance tag must match the package version.");
assert(/^[0-9a-f]{40}$/i.test(provenance.commit || ""), "Desktop release provenance must contain a full commit SHA.");
assert(
provenance.platformSigningMode === desktopReleaseResolution.platformSigningMode,
`Desktop release provenance platformSigningMode must be ${desktopReleaseResolution.platformSigningMode}.`,
);
assert(
provenance.platformSigning?.macos === desktopReleaseResolution.macosSigning,
`Desktop release provenance macOS signing must be ${desktopReleaseResolution.macosSigning}.`,
);
assert(
provenance.platformSigning?.windows === desktopReleaseResolution.windowsSigning,
`Desktop release provenance Windows signing must be ${desktopReleaseResolution.windowsSigning}.`,
);
assert(
provenance.updaterSigning === "required-and-verified",
"Desktop release provenance must record required-and-verified updater signing.",
);
assert(
provenance.artifactLabel === desktopReleaseResolution.artifactLabel,
`Desktop release provenance artifactLabel must be ${desktopReleaseResolution.artifactLabel}.`,
);
if (desktopReleaseResolution.warningRequired) {
assert(
Array.isArray(provenance.warnings) && provenance.warnings.length >= 2,
"Unsigned platform release provenance must include installation trust warnings.",
);
}
if (artifactDir) {
await assertChecksum(checksums, provenancePath, "desktop release provenance");
}
};
let feed;
try {
feed = JSON.parse(await readFile(feedPath, "utf8"));
} catch (error) {
fail(`Cannot read desktop update feed ${feedPath}: ${error.message}`);
}
if (feed) {
const checksums = await readChecksumManifest();
await assertManifestEntries(checksums);
await verifyProvenance(checksums);
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"];
const darwinIntel = platforms["darwin-x86_64"];
assert(normalizedFeedVersion === packageInfo.version, `latest.json version must match package version ${packageInfo.version}.`);
assert(Boolean(feed.pub_date || feed.pubDate), "latest.json must include a publication date.");
for (const platform of requiredPlatforms) {
assert(platformPattern.test(platform), `Required updater platform key is unsupported: ${platform}.`);
assert(Boolean(platforms[platform]), `latest.json must include ${platform}.`);
}
const entries = Object.entries(platforms);
assert(entries.length > 0, "latest.json must include at least one platform entry.");
for (const [platform, entry] of entries) {
assert(platformPattern.test(platform), `latest.json contains an unsupported platform key: ${platform}.`);
const rawUrl = entry?.url;
const signature = entry?.signature;
assert(typeof signature === "string" && signature.length > 80, `${platform} must include an updater signature.`);
assert(typeof rawUrl === "string" && rawUrl.length > 0, `${platform} must include an artifact URL.`);
if (!rawUrl) continue;
let url;
try {
url = new URL(rawUrl);
} catch {
fail(`${platform} artifact URL is invalid: ${rawUrl}`);
continue;
}
assert(url.protocol === "https:", `${platform} artifact URL must use HTTPS.`);
assert(!/[?&]token=/i.test(url.search), `${platform} artifact URL must not contain token query parameters.`);
assert(!url.pathname.endsWith("/latest.json"), `${platform} artifact URL must point to an immutable artifact, not latest.json.`);
assert(url.pathname.includes(packageInfo.version), `${platform} artifact URL must include version ${packageInfo.version}.`);
if (expectedBaseUrl) {
assert(rawUrl.startsWith(expectedBaseUrl), `${platform} artifact URL must start with ${expectedBaseUrl}.`);
}
if (platform.startsWith("darwin-")) {
assert(url.pathname.endsWith(".app.tar.gz"), `${platform} must point to a macOS .app.tar.gz updater artifact.`);
}
if (platform === "windows-x86_64") {
assert(url.pathname.endsWith(".nsis.zip"), "windows-x86_64 must point to an NSIS .nsis.zip updater artifact.");
}
if (artifactDir) {
const artifactPath = resolve(artifactDir, basename(url.pathname));
const signaturePath = `${artifactPath}.sig`;
await assertFileExists(artifactPath, `${platform} updater artifact`);
await assertFileExists(signaturePath, `${platform} updater artifact signature`);
await assertChecksum(checksums, artifactPath, `${platform} updater artifact`);
await assertChecksum(checksums, signaturePath, `${platform} updater artifact signature`);
}
}
if (darwinArm?.url && darwinIntel?.url) {
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact.");
}
if (artifactDir) {
await assertChecksum(checksums, feedPath, "latest.json");
}
}
if (failures.length > 0) {
console.error(`Desktop update feed check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Desktop update feed check passed.");
}