diff --git a/.github/scripts/desktop-macos-preview.test.cjs b/.github/scripts/desktop-macos-preview.test.cjs index 8fd019b94..79e4db42b 100644 --- a/.github/scripts/desktop-macos-preview.test.cjs +++ b/.github/scripts/desktop-macos-preview.test.cjs @@ -3,6 +3,7 @@ const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const { test } = require("node:test"); +const { createHash } = require("node:crypto"); const { prepare, reconcile, read, loadDmg } = require("./desktop-macos-preview.cjs"); const SHA = "a".repeat(40); @@ -10,6 +11,123 @@ const NAME = "Pylon-0.0.32-pr.42.10.1-arm64.dmg"; const PREVIOUS = "Pylon-0.0.32-pr.42.9-arm64.dmg"; const httpError = (status) => Object.assign(new Error(`HTTP ${status}`), { status }); +async function trustedLoaderFixture(t, job) { + const workflow = await fs.readFile( + path.join(__dirname, "../workflows/desktop-macos-preview-publish.yml"), + "utf8", + ); + const section = workflow.split(`\n ${job}:\n`)[1].split(/\n \w+:\n/)[0]; + const script = section.match(/ script: \|\n((?: .*\n?)*)/)[1]; + const run = new (Object.getPrototypeOf(async function () {}).constructor)( + "github", + "context", + "core", + "require", + "process", + script.replace(/^ /gm, ""), + ); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-preview-loader-test-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const source = Buffer.from( + "module.exports = { prepare: async ({ core }) => core.info('prepare'), " + + "reconcile: async ({ core, prNumber }) => core.info(`reconcile:${prNumber}`) };\n", + ); + const data = { + type: "file", + path: ".github/scripts/desktop-macos-preview.cjs", + encoding: "base64", + content: + source + .toString("base64") + .match(/.{1,60}/g) + .join("\n") + "\n", + size: source.length, + sha: createHash("sha1").update(`blob ${source.length}\0`).update(source).digest("hex"), + }; + const requests = []; + const messages = []; + const env = { RUNNER_TEMP: directory, PREVIEW_HELPER_REF: SHA, PREVIEW_PR: "42" }; + const github = { + rest: { + repos: { + getContent: async (request) => { + requests.push(request); + return { data }; + }, + }, + }, + }; + return { + data, + env, + requests, + messages, + directory, + github, + run: () => + run( + github, + { repo: { owner: "pylon-code", repo: "pylon" }, sha: "b".repeat(40) }, + { info: (message) => messages.push(message) }, + require, + { env }, + ), + }; +} + +for (const job of ["prepare", "publish"]) { + test(`${job} loads only the verified helper at the trusted workflow commit`, async (t) => { + const f = await trustedLoaderFixture(t, job); + await f.run(); + assert.deepEqual(f.requests, [ + { + owner: "pylon-code", + repo: "pylon", + path: ".github/scripts/desktop-macos-preview.cjs", + ref: SHA, + }, + ]); + assert.deepEqual(f.messages, [job === "prepare" ? "prepare" : "reconcile:42"]); + const [helperDirectory] = await fs.readdir(f.directory); + const filename = path.join(f.directory, helperDirectory, "desktop-macos-preview.cjs"); + assert.equal((await fs.stat(filename)).mode & 0o777, 0o600); + }); + + for (const [name, mutate] of [ + ["wrong path", (d) => (d.path = "another/helper.cjs")], + ["non-file", (d) => (d.type = "dir")], + ["wrong encoding", (d) => (d.encoding = "none")], + ["missing contents", (d) => delete d.content], + ["oversized content", (d) => (d.size = 1024 * 1024 + 1)], + ["size mismatch", (d) => d.size++], + ["invalid base64", (d) => (d.content += "!")], + ["changed contents", (d) => (d.content = Buffer.from("throw 'untrusted';").toString("base64"))], + ["wrong Git blob digest", (d) => (d.sha = "0".repeat(40))], + ]) { + test(`${job} rejects ${name} before writing or executing helper code`, async (t) => { + const f = await trustedLoaderFixture(t, job); + mutate(f.data); + await assert.rejects(f.run, /preview helper/); + assert.deepEqual(f.messages, []); + assert.deepEqual(await fs.readdir(f.directory), []); + }); + } + + test(`${job} rejects mutable refs and API errors without a fallback`, async (t) => { + const f = await trustedLoaderFixture(t, job); + f.env.PREVIEW_HELPER_REF = "pylon"; + await assert.rejects(f.run, /immutable workflow commit/); + assert.deepEqual(f.requests, []); + f.env.PREVIEW_HELPER_REF = SHA; + f.github.rest.repos.getContent = async () => { + throw httpError(503); + }; + await assert.rejects(f.run, /HTTP 503/); + assert.deepEqual(f.messages, []); + assert.deepEqual(await fs.readdir(f.directory), []); + }); +} + function fixture() { const calls = []; const outputs = {}; diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index f257f1be4..de4bd06e4 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -29,19 +29,39 @@ jobs: mode: ${{ steps.prepare.outputs.mode }} artifact_id: ${{ steps.prepare.outputs.artifact_id }} steps: - # Both triggers execute trusted pylon code. Never substitute the PR head, - # restore build caches, install PR dependencies, or execute artifact contents. - - uses: actions/checkout@v6 - with: - ref: ${{ github.workflow_sha }} - persist-credentials: false - sparse-checkout: .github/scripts + # Load only the helper from this trusted workflow's immutable commit. + # A full Git index includes vendored gitlinks without root submodule metadata, + # which makes actions/checkout credential cleanup fail even with sparse checkout. - id: prepare uses: actions/github-script@v8 + env: + PREVIEW_HELPER_REF: ${{ github.workflow_sha }} with: script: | - const { prepare } = require('./.github/scripts/desktop-macos-preview.cjs'); - await prepare({ github, context, core }); + const operation = 'prepare'; + const fs = require('node:fs/promises'); + const path = require('node:path'); + const { createHash } = require('node:crypto'); + const ref = process.env.PREVIEW_HELPER_REF; + if (!/^[a-f0-9]{40}$/.test(ref ?? '')) throw new Error('Expected an immutable workflow commit'); + const helper = '.github/scripts/desktop-macos-preview.cjs'; + const { data } = await github.rest.repos.getContent({ ...context.repo, path: helper, ref }); + if (data?.type !== 'file' || data.path !== helper || data.encoding !== 'base64' || + typeof data.content !== 'string' || !Number.isSafeInteger(data.size) || + data.size <= 0 || data.size > 1024 * 1024 || !/^[a-f0-9]{40}$/.test(data.sha ?? '')) { + throw new Error('Invalid trusted preview helper response'); + } + const encoded = data.content.replace(/\n/g, ''); + const source = Buffer.from(encoded, 'base64'); + const digest = createHash('sha1').update(`blob ${source.length}\0`).update(source).digest('hex'); + if (source.length !== data.size || source.toString('base64') !== encoded || digest !== data.sha) { + throw new Error('Trusted preview helper content verification failed'); + } + const directory = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP, 'pylon-preview-helper-')); + const filename = path.join(directory, 'desktop-macos-preview.cjs'); + await fs.writeFile(filename, source, { flag: 'wx', mode: 0o600 }); + const { [operation]: run } = require(filename); + await run({ github, context, core }); publish: needs: prepare @@ -58,12 +78,7 @@ jobs: group: desktop-macos-preview-publication-${{ needs.prepare.outputs.pr }} cancel-in-progress: false steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.workflow_sha }} - persist-credentials: false - sparse-checkout: .github/scripts - - name: Download this build's artifact outside the trusted checkout + - name: Download preview artifact if: needs.prepare.outputs.mode == 'publish' uses: actions/download-artifact@v8 with: @@ -77,9 +92,32 @@ jobs: env: PREVIEW_DIRECTORY: ${{ runner.temp }}/pylon-macos-preview PREVIEW_PR: ${{ needs.prepare.outputs.pr }} + PREVIEW_HELPER_REF: ${{ github.workflow_sha }} with: script: | - const { reconcile } = require('./.github/scripts/desktop-macos-preview.cjs'); - await reconcile({ github, context, core, + const operation = 'reconcile'; + const fs = require('node:fs/promises'); + const path = require('node:path'); + const { createHash } = require('node:crypto'); + const ref = process.env.PREVIEW_HELPER_REF; + if (!/^[a-f0-9]{40}$/.test(ref ?? '')) throw new Error('Expected an immutable workflow commit'); + const helper = '.github/scripts/desktop-macos-preview.cjs'; + const { data } = await github.rest.repos.getContent({ ...context.repo, path: helper, ref }); + if (data?.type !== 'file' || data.path !== helper || data.encoding !== 'base64' || + typeof data.content !== 'string' || !Number.isSafeInteger(data.size) || + data.size <= 0 || data.size > 1024 * 1024 || !/^[a-f0-9]{40}$/.test(data.sha ?? '')) { + throw new Error('Invalid trusted preview helper response'); + } + const encoded = data.content.replace(/\n/g, ''); + const source = Buffer.from(encoded, 'base64'); + const digest = createHash('sha1').update(`blob ${source.length}\0`).update(source).digest('hex'); + if (source.length !== data.size || source.toString('base64') !== encoded || digest !== data.sha) { + throw new Error('Trusted preview helper content verification failed'); + } + const directory = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP, 'pylon-preview-helper-')); + const filename = path.join(directory, 'desktop-macos-preview.cjs'); + await fs.writeFile(filename, source, { flag: 'wx', mode: 0o600 }); + const { [operation]: run } = require(filename); + await run({ github, context, core, prNumber: Number(process.env.PREVIEW_PR), directory: process.env.PREVIEW_DIRECTORY });