| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { createHash } from "node:crypto"; |
| 4 | import { execFileSync, spawnSync } from "node:child_process"; |
| 5 | import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; |
| 6 | import path from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; |
| 8 | |
| 9 | export const FRONTEND_ARTIFACT_SCHEMA = 1; |
| 10 | |
| 11 | function sha256(parts) { |
| 12 | const hash = createHash("sha256"); |
| 13 | for (const part of parts) hash.update(part); |
| 14 | return hash.digest("hex"); |
| 15 | } |
| 16 | |
| 17 | function git(root, args, options = {}) { |
| 18 | return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", ...options }).trim(); |
| 19 | } |
| 20 | |
| 21 | function gitBlobContents(root, names) { |
| 22 | const result = spawnSync("git", ["-C", root, "cat-file", "--batch"], { |
| 23 | input: names.map(name => `HEAD:${name}\n`).join(""), |
| 24 | maxBuffer: 256 * 1024 * 1024, |
| 25 | }); |
| 26 | if (result.error) throw result.error; |
| 27 | if (result.status !== 0) throw new Error(result.stderr.toString("utf8").trim() || "git cat-file --batch failed"); |
| 28 | const contents = []; |
| 29 | let offset = 0; |
| 30 | for (const name of names) { |
| 31 | const headerEnd = result.stdout.indexOf(10, offset); |
| 32 | if (headerEnd < 0) throw new Error(`missing git blob header for ${name}`); |
| 33 | const header = result.stdout.subarray(offset, headerEnd).toString("utf8"); |
| 34 | const match = header.match(/^[0-9a-f]+ blob (\d+)$/); |
| 35 | if (!match) throw new Error(`invalid git blob header for ${name}: ${header}`); |
| 36 | const bodyStart = headerEnd + 1; |
| 37 | const bodyEnd = bodyStart + Number(match[1]); |
| 38 | if (bodyEnd >= result.stdout.length || result.stdout[bodyEnd] !== 10) |
| 39 | throw new Error(`truncated git blob for ${name}`); |
| 40 | contents.push(result.stdout.subarray(bodyStart, bodyEnd)); |
| 41 | offset = bodyEnd + 1; |
| 42 | } |
| 43 | if (offset !== result.stdout.length) throw new Error("unexpected trailing git cat-file output"); |
| 44 | return contents; |
| 45 | } |
| 46 | |
| 47 | function filesBelow(directory, relative = "") { |
| 48 | const out = []; |
| 49 | for (const entry of readdirSync(path.join(directory, relative), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { |
| 50 | const name = path.posix.join(relative.replaceAll("\\", "/"), entry.name); |
| 51 | if (entry.isDirectory()) out.push(...filesBelow(directory, name)); |
| 52 | else out.push(name); |
| 53 | } |
| 54 | return out; |
| 55 | } |
| 56 | |
| 57 | export function distIdentity(dist) { |
| 58 | const files = filesBelow(dist).map(name => { |
| 59 | const content = readFileSync(path.join(dist, name)); |
| 60 | return { name, size: content.length, sha256: sha256([content]) }; |
| 61 | }); |
| 62 | return { sha256: sha256(files.flatMap(file => [file.name, "\0", file.sha256, "\0"])), files }; |
| 63 | } |
| 64 | |
| 65 | export function buildInputIdentity(root) { |
| 66 | const names = git(root, ["ls-files", "-z", "--", "desktop/package.json", "desktop/pnpm-lock.yaml", "desktop/pnpm-workspace.yaml", "desktop/frontend"]) |
| 67 | .split("\0").filter(name => name && !name.startsWith("desktop/frontend/dist/")).sort(); |
| 68 | const contents = gitBlobContents(root, names); |
| 69 | return { |
| 70 | // Git blobs are the portable source identity. Reading checkout bytes here |
| 71 | // would make a Windows CRLF checkout disagree with the Linux producer. |
| 72 | sha256: sha256(names.flatMap((name, index) => [name, "\0", contents[index], "\0"])), |
| 73 | files: names, |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | function required(value, name) { |
| 78 | if (!String(value ?? "").trim()) throw new Error(`${name} is required`); |
| 79 | return String(value).trim(); |
| 80 | } |
| 81 | |
| 82 | export function createFrontendArtifact({ root, dist, manifest, shell, channel, sourceSHA, runId, attempt, pnpmVersion }) { |
| 83 | const actualSHA = git(root, ["rev-parse", "HEAD"]); |
| 84 | const expectedSHA = required(sourceSHA, "source SHA"); |
| 85 | if (expectedSHA !== actualSHA) throw new Error(`source SHA mismatch: expected ${expectedSHA}, checkout is ${actualSHA}`); |
| 86 | if (!statSync(dist).isDirectory()) throw new Error(`frontend dist is not a directory: ${dist}`); |
| 87 | const body = { |
| 88 | schemaVersion: FRONTEND_ARTIFACT_SCHEMA, |
| 89 | sourceSHA: actualSHA, |
| 90 | workflow: { runId: required(runId, "run ID"), attempt: required(attempt, "run attempt") }, |
| 91 | variant: { shell: required(shell, "shell"), channel: required(channel, "channel") }, |
| 92 | toolchain: { node: process.version, pnpm: required(pnpmVersion, "pnpm version"), platform: process.platform, arch: process.arch }, |
| 93 | inputs: buildInputIdentity(root), |
| 94 | dist: distIdentity(dist), |
| 95 | }; |
| 96 | writeFileSync(manifest, JSON.stringify(body, null, 2) + "\n"); |
| 97 | return body; |
| 98 | } |
| 99 | |
| 100 | export function verifyFrontendArtifact({ root, dist, manifest, shell, channel, sourceSHA, runId, attempt, pnpmVersion }) { |
| 101 | let body; |
| 102 | try { |
| 103 | body = JSON.parse(readFileSync(manifest, "utf8")); |
| 104 | } catch (error) { |
| 105 | throw new Error(`frontend artifact manifest is unavailable or invalid: ${error.message}`); |
| 106 | } |
| 107 | const expected = { |
| 108 | schemaVersion: FRONTEND_ARTIFACT_SCHEMA, |
| 109 | sourceSHA: sourceSHA || git(root, ["rev-parse", "HEAD"]), |
| 110 | shell, |
| 111 | channel, |
| 112 | runId, |
| 113 | attempt, |
| 114 | node: process.version, |
| 115 | pnpm: pnpmVersion, |
| 116 | }; |
| 117 | const actual = { |
| 118 | schemaVersion: body.schemaVersion, |
| 119 | sourceSHA: body.sourceSHA, |
| 120 | shell: body.variant?.shell, |
| 121 | channel: body.variant?.channel, |
| 122 | runId: body.workflow?.runId, |
| 123 | attempt: body.workflow?.attempt, |
| 124 | node: body.toolchain?.node, |
| 125 | pnpm: body.toolchain?.pnpm, |
| 126 | }; |
| 127 | for (const [name, value] of Object.entries(expected)) { |
| 128 | if (value !== undefined && String(actual[name]) !== String(value)) |
| 129 | throw new Error(`frontend artifact ${name} mismatch: expected ${value}, got ${actual[name]}`); |
| 130 | } |
| 131 | const inputs = buildInputIdentity(root); |
| 132 | if (body.inputs?.sha256 !== inputs.sha256) throw new Error("frontend artifact build inputs do not match this checkout"); |
| 133 | const built = distIdentity(dist); |
| 134 | if (body.dist?.sha256 !== built.sha256 || JSON.stringify(body.dist.files) !== JSON.stringify(built.files)) |
| 135 | throw new Error("frontend artifact contents do not match its manifest"); |
| 136 | return body; |
| 137 | } |
| 138 | |
| 139 | function parseArgs(argv) { |
| 140 | const args = { command: argv[0] }; |
| 141 | for (let i = 1; i < argv.length; i += 2) { |
| 142 | if (!argv[i]?.startsWith("--") || argv[i + 1] === undefined) throw new Error(`invalid argument ${argv[i] ?? ""}`); |
| 143 | args[argv[i].slice(2).replaceAll("-", "_")] = argv[i + 1]; |
| 144 | } |
| 145 | return args; |
| 146 | } |
| 147 | |
| 148 | if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { |
| 149 | try { |
| 150 | const args = parseArgs(process.argv.slice(2)); |
| 151 | const root = path.resolve(args.root ?? path.join(path.dirname(fileURLToPath(import.meta.url)), "../../..")); |
| 152 | const options = { |
| 153 | root, |
| 154 | dist: path.resolve(args.dist ?? path.join(root, "desktop/frontend/dist")), |
| 155 | manifest: path.resolve(args.manifest ?? path.join(root, "desktop/frontend/.reasonix-frontend-artifact.json")), |
| 156 | shell: args.shell, |
| 157 | channel: args.channel, |
| 158 | sourceSHA: args.source_sha, |
| 159 | runId: args.run_id, |
| 160 | attempt: args.attempt, |
| 161 | pnpmVersion: args.pnpm_version ?? execFileSync("pnpm", ["--version"], { encoding: "utf8" }).trim(), |
| 162 | }; |
| 163 | const result = args.command === "create" ? createFrontendArtifact(options) |
| 164 | : args.command === "verify" ? verifyFrontendArtifact(options) |
| 165 | : (() => { throw new Error("command must be create or verify"); })(); |
| 166 | console.log(`frontend artifact ${args.command}: ${result.sourceSHA} ${result.variant.shell}/${result.variant.channel} ${result.dist.sha256}`); |
| 167 | } catch (error) { |
| 168 | console.error(`artifact-identity: ${error.message}`); |
| 169 | process.exitCode = 1; |
| 170 | } |
| 171 | } |
| 172 |