import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { spawnSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parse } from 'yaml'; import { assertBuildSnapWorkflowPolicy, assertPublishSnapWorkflowPolicy, } from './snap-workflow-policy.test-helpers.mjs'; const workspaceRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', '..' ); const buildWorkflowPath = path.join( workspaceRoot, '.github', 'workflows', 'build-and-make.yaml' ); const publishWorkflowPath = path.join( workspaceRoot, '.github', 'workflows', 'publish-snap.yaml' ); const releaseAssetHelperPath = path.join( workspaceRoot, 'tools', 'packaging', 'release-snap-assets.cjs' ); async function loadReleaseAssetHelper() { if (!fs.existsSync(releaseAssetHelperPath)) { return null; } return import(pathToFileURL(releaseAssetHelperPath).href); } function insertFirstJobStep(workflowText, stepSource) { const stepsHeader = /^([ \t]+)steps:\r?\n/m.exec(workflowText); assert.ok(stepsHeader, 'workflow must contain a steps list'); const insertionIndex = stepsHeader.index + stepsHeader[0].length; const stepIndent = `${stepsHeader[1]} `; const indentedStep = stepSource .split('\n') .map((line) => `${stepIndent}${line}`) .join('\n'); return `${workflowText.slice( 0, insertionIndex )}${indentedStep}\n${workflowText.slice(insertionIndex)}`; } function insertWorkflowJob(workflowText, jobSource) { const jobsHeader = /^([ \t]*)jobs:\r?\n/m.exec(workflowText); assert.ok(jobsHeader, 'workflow must contain a jobs mapping'); const insertionIndex = jobsHeader.index + jobsHeader[0].length; const jobIndent = `${jobsHeader[1]} `; const indentedJob = jobSource .split('\n') .map((line) => `${jobIndent}${line}`) .join('\n'); return `${workflowText.slice( 0, insertionIndex )}${indentedJob}\n${workflowText.slice(insertionIndex)}`; } function insertFirstJobField(workflowText, fieldSource) { const stepsHeader = /^([ \t]+)steps:\r?\n/m.exec(workflowText); assert.ok(stepsHeader, 'workflow must contain a steps list'); const fieldIndent = stepsHeader[1]; const indentedField = fieldSource .split('\n') .map((line) => `${fieldIndent}${line}`) .join('\n'); return `${workflowText.slice( 0, stepsHeader.index )}${indentedField}\n${workflowText.slice(stepsHeader.index)}`; } function assertStepRejectedByBothPolicies(stepSource) { for (const [workflowPath, assertPolicy] of [ [publishWorkflowPath, assertPublishSnapWorkflowPolicy], [buildWorkflowPath, assertBuildSnapWorkflowPolicy], ]) { const workflowText = insertFirstJobStep( fs.readFileSync(workflowPath, 'utf8'), stepSource ); assert.doesNotThrow(() => parse(workflowText)); assert.throws(() => assertPolicy(workflowText)); } } test('recovery resolves only an existing public stable release before checkout', (t) => { const workflow = parse(fs.readFileSync(publishWorkflowPath, 'utf8')); const steps = workflow.jobs['verify-snap'].steps; const resolve = steps.find((step) => step.id === 'resolve-release'); assert.ok( resolve, 'recovery must resolve the public release before checkout' ); assert.ok(steps.indexOf(resolve) < steps.findIndex((step) => step.uses)); assert.equal(workflow.on.workflow_dispatch.inputs.tag.required, true); const directory = fs.mkdtempSync( path.join(os.tmpdir(), 'snap-release-resolution-') ); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); fs.writeFileSync( path.join(directory, 'gh'), '#!/bin/sh\ncat "$RELEASE_FIXTURE"\n', { mode: 0o755 } ); const output = path.join(directory, 'output'); const fixture = path.join(directory, 'release.json'); for (const [patch, tag, eventId, succeeds] of [ [{}, 'v0.24.0', '', true], [{}, 'v0.24.0', '123', true], [{ draft: true }, 'v0.24.0', '', false], [{ prerelease: true }, 'v0.24.0', '', false], [{ tag_name: 'v0.25.0' }, 'v0.24.0', '', false], [{}, 'v0.24.0', '456', false], [{}, 'v0.24.0; touch injected', '', false], [{}, '../../master', '', false], ]) { fs.writeFileSync( fixture, JSON.stringify({ id: 123, tag_name: 'v0.24.0', draft: false, prerelease: false, published_at: '2026-09-24T06:58:11Z', ...patch, }) ); fs.writeFileSync(output, ''); const result = spawnSync( 'bash', ['-e', '-o', 'pipefail', '-c', resolve.run], { encoding: 'utf8', env: { ...process.env, PATH: `${directory}:${process.env.PATH}`, RELEASE_FIXTURE: fixture, RUNNER_TEMP: directory, GITHUB_OUTPUT: output, GITHUB_REPOSITORY: '4gray/iptvnator', REQUESTED_TAG: tag, EVENT_RELEASE_ID: eventId, }, } ); assert.equal(result.status === 0, succeeds, result.stderr); if (succeeds) { assert.match( fs.readFileSync(output, 'utf8'), /tag=v0\.24\.0\nrelease-id=123\n/ ); } else { assert.equal(fs.readFileSync(output, 'utf8'), ''); } } }); test( 'Snapcraft scratch siblings are writable while upload payloads cannot be replaced', { skip: process.platform !== 'linux' }, (t) => { const workflow = parse(fs.readFileSync(publishWorkflowPath, 'utf8')); const step = workflow.jobs['publish-snap'].steps.find( (entry) => entry.name === 'Prepare Snapcraft upload workspace' ); assert.ok( step, 'Snapcraft needs a writable sibling directory for metadata extraction' ); const canElevate = process.getuid() === 0 || spawnSync('sudo', ['-n', 'true']).status === 0; const canDropPrivileges = spawnSync('/usr/bin/setpriv', ['--version']).status === 0; if (!canElevate || !canDropPrivileges) { const reason = 'Snap upload permission integration requires root or passwordless sudo and /usr/bin/setpriv'; assert.ok(!process.env.CI, reason); t.skip(reason); return; } const directory = fs.mkdtempSync( path.join(os.tmpdir(), 'snap-upload-permissions-') ); const asRoot = (command) => spawnSync( process.getuid() === 0 ? 'bash' : 'sudo', process.getuid() === 0 ? ['-e', '-c', command] : ['-n', 'bash', '-e', '-c', command], { encoding: 'utf8' } ); t.after(() => asRoot(`rm -rf '${directory}'`)); const sealed = path.join(directory, 'sealed'); const upload = path.join(directory, 'upload'); const setup = asRoot( `chmod 0755 '${directory}'; mkdir '${sealed}'; printf payload > '${sealed}/package.snap'; chown -R root:root '${sealed}'; chmod 0444 '${sealed}/package.snap'; chmod 0555 '${sealed}'` ); assert.equal(setup.status, 0, setup.stderr); const prepare = asRoot( step.run .replaceAll('/var/lib/iptvnator-snap-release/assets', sealed) .replaceAll('/var/lib/iptvnator-snap-upload', upload) .replaceAll('sudo ', '') ); assert.equal(prepare.status, 0, prepare.stderr); const checks = ` const fs = require('node:fs'); const assert = require('node:assert/strict'); const sealed = ${JSON.stringify(sealed)}; const upload = ${JSON.stringify(upload)}; assert.throws(() => fs.mkdtempSync(sealed + '/tmp-'), { code: 'EACCES' }); const scratch = fs.mkdtempSync(upload + '/tmp-'); fs.rmdirSync(scratch); assert.equal(fs.statSync(upload).uid, 0); assert.equal(fs.statSync(upload).mode & 0o1777, 0o1777); assert.equal(fs.statSync(upload + '/package.snap').ino, fs.statSync(sealed + '/package.snap').ino); assert.throws(() => fs.writeFileSync(upload + '/package.snap', 'changed'), { code: 'EACCES' }); assert.throws(() => fs.unlinkSync(upload + '/package.snap'), { code: 'EPERM' }); fs.writeFileSync(upload + '/replacement', 'changed'); assert.throws(() => fs.renameSync(upload + '/replacement', upload + '/package.snap'), { code: 'EPERM' }); assert.equal(fs.readFileSync(sealed + '/package.snap', 'utf8'), 'payload'); `; // Nobody models an unprivileged uploader even when the test runs in a root container. const result = asRoot( `/usr/bin/setpriv --reuid=65534 --regid=65534 --clear-groups '${process.execPath}' -e '${checks.replaceAll("'", "'\\''")}'` ); assert.equal(result.status, 0, result.stderr); } ); test('publishes Snap only after a public v-tag release contains binary and source assets', () => { assert.equal( fs.existsSync(publishWorkflowPath), true, 'the release-published Snap workflow must exist' ); const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); assert.match(workflowText, /^permissions:\n {4}contents: read$/m); assert.match( workflowText, /startsWith\(github\.event\.release\.tag_name,\s*'v'\)/ ); assert.match(workflowText, /github\.event\.release\.draft\s*==\s*false/); const validateIndex = workflowText.indexOf( '- name: Select exact public release assets' ); const verifyIndex = workflowText.indexOf( '- name: Verify downloaded public release assets' ); const uploadIndex = workflowText.indexOf( '- name: Publish all public-release snaps to edge' ); assert.ok(validateIndex >= 0); assert.ok(verifyIndex > validateIndex); assert.ok(uploadIndex > verifyIndex); assert.match( workflowText, /github\.event\.release\.id[\s\S]*release-snap-assets\.cjs select/ ); assert.match(workflowText, /linux-frame-copy-runtime-sources\.tar\.xz/); assert.match(workflowText, /release-snap-assets\.cjs verify/); assertPublishSnapWorkflowPolicy(workflowText); const disabledWorkflow = workflowText.replace( 'github.event.release.draft == false)', 'github.event.release.draft == false && false)' ); assert.notEqual(disabledWorkflow, workflowText); assert.doesNotThrow(() => parse(disabledWorkflow)); assert.throws(() => assertPublishSnapWorkflowPolicy(disabledWorkflow)); const extraTrigger = workflowText.replace( ' - published', ' - published\n - edited' ); assert.doesNotThrow(() => parse(extraTrigger)); assert.throws(() => assertPublishSnapWorkflowPolicy(extraTrigger)); assert.match( workflowText, /Candidate\/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke/ ); const buildWorkflow = fs.readFileSync(buildWorkflowPath, 'utf8'); assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m); assertBuildSnapWorkflowPolicy(buildWorkflow); }); test('isolates released verification from the fresh credentialed upload runner', () => { const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); const workflow = parse(workflowText); assert.deepEqual(Object.keys(workflow.jobs), [ 'verify-snap', 'publish-snap', ]); const verifyJob = workflow.jobs['verify-snap']; const publishJob = workflow.jobs['publish-snap']; assert.equal(publishJob.needs, 'verify-snap'); assert.deepEqual(verifyJob.outputs, { 'receipt-sha256': '${{ steps.bind-transfer.outputs.receipt-sha256 }}', }); assert.equal(JSON.stringify(verifyJob).includes('snapcraft_token'), false); assert.deepEqual( verifyJob.steps.filter((step) => step.uses).map((step) => step.uses), [ 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a', ] ); assert.deepEqual( publishJob.steps.filter((step) => step.uses).map((step) => step.uses), ['actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c'] ); const bindingStep = verifyJob.steps.find( (step) => step.name === 'Bind verified release transfer' ); assert.equal(bindingStep.id, 'bind-transfer'); assert.match( bindingStep.run, /\/usr\/bin\/sha256sum --binary[\s\S]*receipt-sha256/ ); const transferredSealStep = publishJob.steps.find( (step) => step.name === 'Seal transferred public release assets' ); assert.deepEqual(transferredSealStep.env, { EXPECTED_RECEIPT_SHA256: '${{ needs.verify-snap.outputs.receipt-sha256 }}', }); assert.match( transferredSealStep.run, /\/usr\/bin\/sha256sum --binary[\s\S]*EXPECTED_RECEIPT_SHA256/ ); assert.match( transferredSealStep.run, /\/usr\/bin\/jq --exit-status[\s\S]*\/usr\/bin\/sha256sum --strict --check/ ); assert.match( transferredSealStep.run, /\/usr\/bin\/jq --raw-output[\s\S]*@tsv[\s\S]*while IFS=\$'\\t' read -r ASSET_NAME EXPECTED_SIZE[\s\S]*\/usr\/bin\/stat --format=%s -- "\$\{ASSET_PATH\}"[\s\S]*test "\$\{ACTUAL_SIZE\}" = "\$\{EXPECTED_SIZE\}"/ ); const uploadJobCommands = publishJob.steps .map((step) => step.run ?? '') .join('\n'); assert.doesNotMatch(uploadJobCommands, /\bnode\b/); assert.doesNotMatch(uploadJobCommands, /release-snap-assets\.cjs/); const uploadStep = publishJob.steps.at(-1); assert.deepEqual(uploadStep.env, { SNAPCRAFT_STORE_CREDENTIALS: '${{ secrets.snapcraft_token }}', }); assert.match(uploadStep.run, /shopt -s nullglob dotglob/); assert.match( uploadStep.run, /SNAP_FILES=\("\$\{VERIFIED_ASSET_DIRECTORY\}"\/\*\.snap\)/ ); assert.match( uploadStep.run, /SNAPCRAFT_STORE_CREDENTIALS="\$\{STORE_CREDENTIALS\}" \/snap\/bin\/snapcraft upload --release=edge/ ); assert.doesNotMatch(uploadStep.run, /\bfind\b|\bsort\b|\bnode\b/); assertPublishSnapWorkflowPolicy(workflowText); }); test('rejects environment and execution-surface expansion on the fresh publish runner', () => { const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); for (const mutatedWorkflow of [ workflowText.replace( ' publish-snap:\n', ' publish-snap:\n env:\n BASH_ENV: /tmp/release-hook\n' ), workflowText.replace( ' runs-on: ubuntu-latest\n timeout-minutes: 20', ' runs-on: ubuntu-latest\n container: ubuntu:latest\n timeout-minutes: 20' ), workflowText.replace( 'permissions:\n contents: read', 'env:\n BASH_ENV: /tmp/release-hook\n\npermissions:\n contents: read' ), workflowText.replaceAll( 'runs-on: ubuntu-latest', 'runs-on: self-hosted' ), workflowText.replace( ' timeout-minutes: 20', ' timeout-minutes: 120' ), ]) { assert.notEqual(mutatedWorkflow, workflowText); assert.doesNotThrow(() => parse(mutatedWorkflow)); assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow)); } }); test('rejects Snap uploads that target candidate or stable channels', () => { const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); for (const forbiddenReleaseArgument of [ '--release=edge,candidate,stable', '--release edge,candidate,stable', '--release=candidate', '--release stable', ]) { const mixedChannelWorkflow = workflowText.replace( '--release=edge', forbiddenReleaseArgument ); assert.notEqual(mixedChannelWorkflow, workflowText); assert.doesNotThrow(() => parse(mixedChannelWorkflow)); assert.throws(() => assertPublishSnapWorkflowPolicy(mixedChannelWorkflow) ); } }); test('rejects edge upload text in non-executing shell contexts', () => { const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); const edgeUpload = 'SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${UPLOAD_DIRECTORY}/${SNAP_NAME}"'; const blockIndent = ' '.repeat(18); for (const replacement of [ `cat <<123\n${edgeUpload}\n123`, `cat <<\\EOF\n${edgeUpload}\nEOF`, `printf '%s\\n' "\n${edgeUpload}\n"`, `publish_snap() {\n${edgeUpload}\n}`, `if false; then\n${edgeUpload}\nfi`, `cat < parse(mutatedWorkflow)); assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow)); } }); test('rejects extra CLI tokens across wrappers, YAML forms, quotes, and heredocs', () => { const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); for (const stepSource of [ '- run: command snapcraft upload --release=stable package.snap', '- run: |\n if true; then command snapcraft \\\n upload --release=edge,candidate,stable package.snap; fi', '- run: |\n snap\\\n craft upload --release=stable package.snap', '- run: |\n snapcraft up\\\n load --release=stable package.snap', '- run: |2\n snapcraft upload --release=stable package.snap', '- run: snapcraft upload --release=stable package.snap', '- { run: snapcraft upload --release=stable package.snap }', '- run: echo "snapcraft upload --release=edge package.snap"', "- run: |\n cat <<'EOF'\n snapcraft upload --release=edge package.snap\n EOF", '- run: command snapcraft release iptvnator stable', ]) { const mutatedWorkflow = insertFirstJobStep(workflowText, stepSource); assert.doesNotThrow(() => parse(mutatedWorkflow)); assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow)); } }); test('rejects continued uploads and release commands in the build workflow', () => { const workflowText = fs.readFileSync(buildWorkflowPath, 'utf8'); for (const stepSource of [ '- run: |\n if true; then command snapcraft \\\n upload --release=edge package.snap; fi', '- run: command snapcraft release iptvnator edge', ]) { const mutatedWorkflow = insertFirstJobStep(workflowText, stepSource); assert.doesNotThrow(() => parse(mutatedWorkflow)); assert.throws(() => assertBuildSnapWorkflowPolicy(mutatedWorkflow)); } }); test('rejects encoded, indirect, and unknown actions in both workflows', () => { for (const stepSource of [ '- uses: snapcore/action-publish@v1\n with:\n release: stable', '- "uses": snapcore/action-publish@v1\n with:\n release: stable', "- 'uses' : snapcore/action-publish@v1\n with:\n release: stable", '- "\\x75ses": snapcore/action-publish@v1\n with:\n release: stable', '- ? uses\n : snapcore/action-publish@v1\n with:\n release: stable', '- { uses: snapcore/action-publish@v1, with: { release: stable } }', '- uses: >-\n snapcore/action-publish@v1\n with:\n release: stable', '- name: Alias publisher\n env:\n PUBLISHER: &publisher snapcore/action-publish@v1\n uses: *publisher\n with:\n release: stable', '- uses: "\\x73napcore/action-publish@v1"\n with:\n release: stable', '- uses: example/action-publish@v1\n with:\n release: stable', ]) { assertStepRejectedByBothPolicies(stepSource); } }); test('rejects job-level reusable workflow publication in both workflows', () => { const reusableJob = 'synthetic-publisher:\n uses: snapcore/action-publish/.github/workflows/publish.yml@v1\n with:\n release: stable\n secrets: inherit'; for (const [workflowPath, assertPolicy] of [ [publishWorkflowPath, assertPublishSnapWorkflowPolicy], [buildWorkflowPath, assertBuildSnapWorkflowPolicy], ]) { const workflowText = insertWorkflowJob( fs.readFileSync(workflowPath, 'utf8'), reusableJob ); assert.doesNotThrow(() => parse(workflowText)); assert.throws(() => assertPolicy(workflowText)); } }); test('rejects command-bearing explicit shell templates in both workflows', () => { const maliciousShell = '"snapcraft release iptvnator stable; bash {0}"'; for (const [workflowPath, assertPolicy] of [ [publishWorkflowPath, assertPublishSnapWorkflowPolicy], [buildWorkflowPath, assertBuildSnapWorkflowPolicy], ]) { const workflowText = fs.readFileSync(workflowPath, 'utf8'); for (const mutatedWorkflow of [ insertFirstJobStep( workflowText, `- shell: ${maliciousShell}\n run: echo safe` ), `${workflowText}\ndefaults:\n run:\n shell: ${maliciousShell}\n`, insertFirstJobField( workflowText, `defaults:\n run:\n shell: ${maliciousShell}` ), ]) { assert.doesNotThrow(() => parse(mutatedWorkflow)); assert.throws(() => assertPolicy(mutatedWorkflow)); } } }); test('ignores Snapcraft names in comments and allowlisted action uses', () => { const commentStep = '- run: |\n # snapcraft upload --release=stable package.snap\n # snapcraft release iptvnator stable'; const publishWorkflow = insertFirstJobStep( fs.readFileSync(publishWorkflowPath, 'utf8'), commentStep ); const buildWorkflow = insertFirstJobStep( fs.readFileSync(buildWorkflowPath, 'utf8'), commentStep ); assert.doesNotThrow(() => parse(publishWorkflow)); assert.doesNotThrow(() => parse(buildWorkflow)); assert.doesNotThrow(() => assertPublishSnapWorkflowPolicy(publishWorkflow)); assert.doesNotThrow(() => assertBuildSnapWorkflowPolicy(buildWorkflow)); }); test('selects every exact Snap and exactly one compliance source asset', async () => { const helper = await loadReleaseAssetHelper(); assert.ok(helper, 'the release asset selection helper must exist'); const selected = helper.selectSnapReleaseAssets([ { id: 9, name: 'IPTVnator-1.0.0-amd64.snap' }, { id: 3, name: 'linux-frame-copy-runtime-sources.tar.xz' }, { id: 8, name: 'IPTVnator-1.0.0-armhf.snap' }, { id: 7, name: 'IPTVnator-1.0.0.AppImage' }, ]); assert.deepEqual(selected, { snapAssets: [ { id: 9, name: 'IPTVnator-1.0.0-amd64.snap' }, { id: 8, name: 'IPTVnator-1.0.0-armhf.snap' }, ], sourceAsset: { id: 3, name: 'linux-frame-copy-runtime-sources.tar.xz', }, }); }); test('rejects a release missing either exact asset class or containing ambiguous source assets', async () => { const helper = await loadReleaseAssetHelper(); assert.ok(helper, 'the release asset selection helper must exist'); assert.throws( () => helper.selectSnapReleaseAssets([ { id: 1, name: 'linux-frame-copy-runtime-sources.tar.xz', }, ]), /at least one \.snap asset/ ); assert.throws( () => helper.selectSnapReleaseAssets([{ id: 1, name: 'IPTVnator.snap' }]), /exactly one linux-frame-copy-runtime-sources\.tar\.xz/ ); assert.throws( () => helper.selectSnapReleaseAssets([ { id: 1, name: 'IPTVnator.snap' }, { id: 2, name: 'linux-frame-copy-runtime-sources.tar.xz', }, { id: 3, name: 'linux-frame-copy-runtime-sources.tar.xz', }, ]), /exactly one linux-frame-copy-runtime-sources\.tar\.xz/ ); }); test('verifies the complete selected download set before publication', async (t) => { const helper = await loadReleaseAssetHelper(); assert.ok(helper, 'the release asset selection helper must exist'); const temporaryRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'iptvnator-snap-release-') ); t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); const manifest = { snapAssets: [{ id: 1, name: 'IPTVnator.snap' }], sourceAsset: { id: 2, name: 'linux-frame-copy-runtime-sources.tar.xz', }, }; fs.writeFileSync(path.join(temporaryRoot, 'IPTVnator.snap'), 'snap'); assert.throws( () => helper.verifySnapReleaseDownloads(manifest, temporaryRoot), /missing or empty.*linux-frame-copy-runtime-sources\.tar\.xz/i ); fs.writeFileSync( path.join(temporaryRoot, 'linux-frame-copy-runtime-sources.tar.xz'), 'sources' ); assert.deepEqual( helper.verifySnapReleaseDownloads(manifest, temporaryRoot), manifest ); });