mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
* fix(release): allow Snapcraft scratch extraction and retry public releases * test(release): detect local Snap permission test prerequisites
655 lines
26 KiB
JavaScript
655 lines
26 KiB
JavaScript
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 <<FIRST <<SECOND\nfirst\nFIRST\n${edgeUpload}\nSECOND`,
|
|
`cat <<-'EOF'\n\t${edgeUpload}\n\tEOF`,
|
|
`cat <<'EOF'\n${edgeUpload}\nEOF`,
|
|
]) {
|
|
const indentedReplacement = replacement.replaceAll(
|
|
'\n',
|
|
`\n${blockIndent}`
|
|
);
|
|
const mutatedWorkflow = workflowText.replace(
|
|
edgeUpload,
|
|
indentedReplacement
|
|
);
|
|
assert.notEqual(mutatedWorkflow, workflowText);
|
|
assert.doesNotThrow(() => 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
|
|
);
|
|
});
|