1// Release files, for the releases page. A release is a commit on2// refs/releases/<tag> whose tree is its files plus SHA256SUMS and3// SHA256SUMS.sig, an ssh-keygen signature (namespace forge-release) of4// SHA256SUMS behind a header naming the repo and tag (releaseMessage), by5// the key of the repo's owner or a collaborator (forge release publish;6// pre-receive checks it all). The hooks and the build hard-link the files under7// /<owner>/<repo>/releases/download/<tag>/; this module reads what the page8// shows about them: each file's size and sha256, who signed SHA256SUMS, and9// where the release came from.10import { spawnSync } from 'node:child_process';11import fs from 'node:fs';12import os from 'node:os';13import path from 'node:path';14import { catFileBatch, tryRun } from './git.ts';15import { signersFor } from './sites.ts';16import type { RepoCache } from './cache.ts';17import type { Ref, ReleaseFiles, RepoUrls } from './types.ts';1819export const NAMESPACE = 'forge-release';20const SUMS = 'SHA256SUMS';21const SIG = 'SHA256SUMS.sig';22const FMT = ['%H', '%cI', '%(trailers:key=Source-Commit,valueonly,separator=%x2c)', '%(trailers:key=Built-By,valueonly,separator=%x2c)'].join('%x1f');2324// "<sha256> <name>" lines, as sha256sum writes them ("*<name>" in binary mode).25export function parseSums(text: string): Map<string, string> {26 const out = new Map<string, string>();27 for (const line of text.split('\n')) {28 const m = /^([0-9a-f]{64}) [ *](\S+)$/.exec(line);29 if (m) out.set(m[2], m[1]);30 }31 return out;32}3334// What SHA256SUMS.sig signs; bin/forge's release_message writes the same.35export function releaseMessage(slug: string, tag: string, sums: Buffer): Buffer {36 return Buffer.concat([Buffer.from(`forge-release 1\nrepo ${slug}\ntag ${tag}\n`), sums]);37}3839function verify(message: Buffer, sig: Buffer, signers: string): { signed: 'good' | 'bad' | 'unknown'; signer: string | null } {40 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-release-'));41 try {42 const file = path.join(dir, 'sig');43 fs.writeFileSync(file, sig);44 const find = spawnSync('ssh-keygen', ['-Y', 'find-principals', '-s', file, '-f', signers], { encoding: 'utf8' });45 const signer = find.status === 0 ? find.stdout.split('\n')[0].trim() || null : null;46 if (!signer) return { signed: 'unknown', signer: null };47 const v = spawnSync('ssh-keygen', ['-Y', 'verify', '-f', signers, '-I', signer, '-n', NAMESPACE, '-s', file], { input: message });48 return { signed: v.status === 0 ? 'good' : 'bad', signer };49 } finally {50 fs.rmSync(dir, { recursive: true, force: true });51 }52}5354interface LoadOptions {55 slug: string;56 urls: RepoUrls;57 allowedSigners: string;58 cache: RepoCache;59}6061// Every release of the repo, by tag. Signature checks are remembered by the62// blob ids of SHA256SUMS and its signature, until the allowed signers change.63export function loadReleases(gitDir: string, refs: Ref[], { slug, urls, allowedSigners, cache }: LoadOptions): Map<string, ReleaseFiles> {64 const out = new Map<string, ReleaseFiles>();65 const list = refs.filter((r) => r.name.startsWith('refs/releases/'));66 if (!list.length) return out;67 const meta = new Map<string, string[]>();68 const log = tryRun(gitDir, ['log', '--no-walk=unsorted', '--stdin', `--format=${FMT}%x1e`], { input: list.map((r) => r.sha).join('\n') + '\n' }) ?? '';69 for (const rec of log.split('\x1e').map((s) => s.trim()).filter(Boolean)) {70 const f = rec.split('\x1f');71 meta.set(f[0], f);72 }73 const signers = signersFor(allowedSigners, cache);74 for (const r of list) {75 const tag = r.name.slice('refs/releases/'.length);76 const entries = (tryRun(gitDir, ['ls-tree', '-l', '-z', r.sha]) ?? '').split('\0').filter(Boolean).map((rec) => {77 const tab = rec.indexOf('\t');78 const [, , oid, size] = rec.slice(0, tab).split(/ +/);79 return { name: rec.slice(tab + 1), oid, size: Number(size) };80 });81 const sums = entries.find((e) => e.name === SUMS);82 const sig = entries.find((e) => e.name === SIG);83 const blobs = catFileBatch(gitDir, [sums?.oid, sig?.oid].filter((x): x is string => !!x));84 const sumsData = sums ? blobs.get(sums.oid)?.data : undefined;85 const sigData = sig ? blobs.get(sig.oid)?.data : undefined;86 const hashes = parseSums(sumsData?.toString('utf8') ?? '');87 const check = sums && sig && sumsData && sigData && signers ? (cache.sigs[`release ${slug} ${tag} ${sums.oid} ${sig.oid}`] ??= verify(releaseMessage(slug, tag, sumsData), sigData, signers)) : {};88 const [, date = r.date, source = '', builtBy = ''] = meta.get(r.sha) ?? [];89 out.set(tag, {90 tag, sha: r.sha, date, source: source.trim() || null, builtBy: builtBy.trim() || null,91 files: entries.filter((e) => e !== sums && e !== sig).map((e) => ({ name: e.name, size: e.size, sha256: hashes.get(e.name) ?? null, href: urls.download(tag, e.name).href })),92 sums: urls.download(tag, SUMS).href,93 sig: urls.download(tag, SIG).href,94 signed: check.signed ?? null,95 signer: check.signer ?? null,96 });97 }98 return out;99}