import { access, readFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const frontendDir = fileURLToPath(new URL("../", import.meta.url)); const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); const failures = []; const args = process.argv.slice(2); const optionValue = (name) => { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; }; 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 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}`); } } }; 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); 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."); assert(Boolean(darwinArm), "latest.json must include darwin-aarch64."); assert(Boolean(darwinIntel), "latest.json must include darwin-x86_64."); const entries = [ ["darwin-aarch64", darwinArm], ["darwin-x86_64", darwinIntel], ]; for (const [platform, entry] of entries) { 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 (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."); }