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
236 lines
9.6 KiB
JavaScript
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.");
|
|
}
|