diff --git a/README.md b/README.md index 4698c3b..2845003 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ attest: contents: write # TODO: Update this ``` - The `id-token` permission gives the action the ability to mint the OIDC - token necessary to request a Sigstore signing certificate. The `contents` + The `id-token` permission gives the action the ability to mint the OIDC token + necessary to request a Sigstore signing certificate. The `contents` permission is necessary to persist the attestation. 1. Add the following to your workflow after your artifact has been built: @@ -99,9 +99,9 @@ See [action.yml](action.yml) -| Name | Description | Example | -| ------------- | -------------------------------------------------------------- | ----------------------- | -| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestaion.jsonl` | +| Name | Description | Example | +| ------------- | -------------------------------------------------------------- | ------------------------ | +| `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.jsonl` | diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index a9dbbb5..67f1f95 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -9,6 +9,7 @@ import * as core from '@actions/core' import * as github from '@actions/github' import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock' +import * as oci from '@sigstore/oci' import nock from 'nock' import { SEARCH_PUBLIC_GOOD_URL } from '../src/endpoints' import * as main from '../src/main' @@ -43,7 +44,7 @@ describe('action', () => { 'base64' )}.}` - const subjectName = 'subject' + const subjectName = 'registry/foo/bar' const subjectDigest = 'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32' const predicate = '{}' @@ -189,6 +190,9 @@ describe('action', () => { }) describe('when the repository is public', () => { + const getRegCredsSpy = jest.spyOn(oci, 'getRegistryCredentials') + const attachArtifactSpy = jest.spyOn(oci, 'attachArtifactToImage') + const inputs = { 'subject-digest': subjectDigest, 'subject-name': subjectName, @@ -206,13 +210,26 @@ describe('action', () => { // Mock the action's inputs getInputMock.mockImplementation(mockInput(inputs)) - getBooleanInputMock.mockImplementation(() => false) + // This is where we mock the push-to-registry input + getBooleanInputMock.mockImplementation(() => true) await mockFulcio({ baseURL: 'https://fulcio.sigstore.dev', strict: false }) await mockRekor({ baseURL: 'https://rekor.sigstore.dev' }) + + getRegCredsSpy.mockImplementation(() => ({ + username: 'username', + password: 'password' + })) + attachArtifactSpy.mockImplementation(async () => + Promise.resolve({ + digest: 'sha256:123456', + mediaType: 'application/vnd.cncf.notary.v2', + size: 123456 + }) + ) }) it('invokes the action w/o error', async () => { @@ -220,6 +237,8 @@ describe('action', () => { expect(runMock).toHaveReturned() expect(setFailedMock).not.toHaveBeenCalled() + expect(getRegCredsSpy).toHaveBeenCalledWith(subjectName) + expect(attachArtifactSpy).toHaveBeenCalled() expect(infoMock).toHaveBeenNthCalledWith( 1, expect.stringMatching( diff --git a/__tests__/predicate.test.ts b/__tests__/predicate.test.ts new file mode 100644 index 0000000..2c64af8 --- /dev/null +++ b/__tests__/predicate.test.ts @@ -0,0 +1,92 @@ +import fs from 'fs/promises' +import os from 'os' +import path from 'path' +import { predicateFromInputs } from '../src/predicate' + +describe('subjectFromInputs', () => { + afterEach(() => { + process.env['INPUT_PREDICATE'] = '' + process.env['INPUT_PREDICATE-PATH'] = '' + process.env['INPUT_PREDICATE-TYPE'] = '' + }) + + describe('when no inputs are provided', () => { + it('throws an error', () => { + expect(() => predicateFromInputs()).toThrow(/predicate-type/i) + }) + }) + + describe('when neither predicate path nor predicate are provided', () => { + beforeEach(() => { + process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate' + }) + + it('throws an error', () => { + expect(() => predicateFromInputs()).toThrow( + /one of predicate-path or predicate must be provided/i + ) + }) + }) + + describe('when both predicate path and predicate are provided', () => { + beforeEach(() => { + process.env['INPUT_PREDICATE-PATH'] = 'path/to/predicate' + process.env['INPUT_PREDICATE'] = '{}' + process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate' + }) + + it('throws an error', () => { + expect(() => predicateFromInputs()).toThrow( + /only one of predicate-path or predicate may be provided/i + ) + }) + }) + + describe('when specifying a predicate path', () => { + let dir = '' + const filename = 'subject' + const content = '{}' + + beforeEach(async () => { + // Set-up temp directory + const tmpDir = await fs.realpath(os.tmpdir()) + dir = await fs.mkdtemp(tmpDir + path.sep) + + // Write file to temp directory + await fs.writeFile(path.join(dir, filename), content) + }) + + afterEach(async () => { + // Clean-up temp directory + await fs.rm(dir, { recursive: true }) + }) + + beforeEach(() => { + process.env['INPUT_PREDICATE-PATH'] = path.join(dir, filename) + process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate' + }) + + it('returns the predicate', () => { + expect(predicateFromInputs()).toEqual({ + type: 'https://example.com/predicate', + params: {} + }) + }) + }) + + describe('when specifying a predicate value', () => { + const content = '{}' + + beforeEach(() => { + process.env['INPUT_PREDICATE'] = content + process.env['INPUT_PREDICATE-TYPE'] = 'https://example.com/predicate' + }) + + it('returns the predicate', () => { + expect(predicateFromInputs()).toEqual({ + type: 'https://example.com/predicate', + params: {} + }) + }) + }) +}) diff --git a/__tests__/subject.test.ts b/__tests__/subject.test.ts new file mode 100644 index 0000000..39fe0ec --- /dev/null +++ b/__tests__/subject.test.ts @@ -0,0 +1,205 @@ +import crypto from 'crypto' +import fs from 'fs/promises' +import os from 'os' +import path from 'path' +import { subjectFromInputs } from '../src/subject' + +describe('subjectFromInputs', () => { + afterEach(() => { + process.env['INPUT_SUBJECT-PATH'] = '' + process.env['INPUT_SUBJECT-DIGEST'] = '' + process.env['INPUT_SUBJECT-NAME'] = '' + }) + + describe('when no inputs are provided', () => { + it('throws an error', async () => { + await expect(subjectFromInputs()).rejects.toThrow( + /one of subject-path or subject-digest must be provided/i + ) + }) + }) + + describe('when both subject path and subject digest are provided', () => { + beforeEach(() => { + process.env['INPUT_SUBJECT-PATH'] = 'path/to/subject' + process.env['INPUT_SUBJECT-DIGEST'] = 'digest' + }) + + it('throws an error', async () => { + await expect(subjectFromInputs()).rejects.toThrow( + /only one of subject-path or subject-digest may be provided/i + ) + }) + }) + + describe('when subject digest is provided but not the name', () => { + beforeEach(() => { + process.env['INPUT_SUBJECT-DIGEST'] = 'digest' + }) + + it('throws an error', async () => { + await expect(subjectFromInputs()).rejects.toThrow( + /subject-name must be provided when using subject-digest/i + ) + }) + }) + + describe('when specifying a subject digest', () => { + const name = 'subject' + + describe('when the digest is malformed', () => { + beforeEach(() => { + process.env['INPUT_SUBJECT-DIGEST'] = 'digest' + process.env['INPUT_SUBJECT-NAME'] = 'subject' + }) + + it('throws an error', async () => { + await expect(subjectFromInputs()).rejects.toThrow( + /subject-digest must be in the format "sha256:"/i + ) + }) + }) + + describe('when the alogrithm is not supported', () => { + beforeEach(() => { + process.env['INPUT_SUBJECT-DIGEST'] = 'md5:deadbeef' + process.env['INPUT_SUBJECT-NAME'] = 'subject' + }) + + it('throws an error', async () => { + await expect(subjectFromInputs()).rejects.toThrow( + /subject-digest must be in the format "sha256:"/i + ) + }) + }) + + describe('when the sha256 digest is malformed', () => { + beforeEach(() => { + process.env['INPUT_SUBJECT-DIGEST'] = 'sha256:deadbeef' + process.env['INPUT_SUBJECT-NAME'] = 'subject' + }) + + it('throws an error', async () => { + await expect(subjectFromInputs()).rejects.toThrow( + /subject-digest must be in the format "sha256:"/i + ) + }) + }) + + describe('when the sha256 digest is valid', () => { + const alg = 'sha256' + const digest = + '7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32' + + beforeEach(() => { + process.env['INPUT_SUBJECT-DIGEST'] = `${alg}:${digest}` + process.env['INPUT_SUBJECT-NAME'] = name + }) + + it('returns the subject', async () => { + const subject = await subjectFromInputs() + + expect(subject).toBeDefined() + expect(subject).toHaveLength(1) + expect(subject[0].name).toEqual(name) + expect(subject[0].digest).toEqual({ [alg]: digest }) + }) + }) + }) + + describe('when specifying a subject path', () => { + describe('when the file does NOT exist', () => { + beforeEach(() => { + process.env['INPUT_SUBJECT-PATH'] = '/f/a/k/e' + }) + + it('throws an error', async () => { + await expect(subjectFromInputs()).rejects.toThrow( + /could not find subject at path/i + ) + }) + }) + }) + + describe('when the file eixts', () => { + let dir = '' + const filename = 'subject' + const content = 'file content' + + const expectedDigest = crypto + .createHash('sha256') + .update(content) + .digest('hex') + + beforeEach(async () => { + // Set-up temp directory + const tmpDir = await fs.realpath(os.tmpdir()) + dir = await fs.mkdtemp(tmpDir + path.sep) + + // Write file to temp directory + await fs.writeFile(path.join(dir, filename), content) + + // Add files for glob testing + for (let i = 0; i < 3; i++) { + await fs.writeFile(path.join(dir, `${filename}-${i}`), content) + } + }) + + afterEach(async () => { + // Clean-up temp directory + await fs.rm(dir, { recursive: true }) + }) + + describe('when no name is provided', () => { + beforeEach(() => { + process.env['INPUT_SUBJECT-PATH'] = path.join(dir, filename) + }) + + it('returns the subject', async () => { + const subject = await subjectFromInputs() + + expect(subject).toBeDefined() + expect(subject).toHaveLength(1) + expect(subject[0].name).toEqual(filename) + expect(subject[0].digest).toEqual({ sha256: expectedDigest }) + }) + }) + + describe('when a name is provided', () => { + const name = 'mysubject' + + beforeEach(() => { + process.env['INPUT_SUBJECT-PATH'] = path.join(dir, filename) + process.env['INPUT_SUBJECT-NAME'] = name + }) + + it('returns the subject', async () => { + const subject = await subjectFromInputs() + + expect(subject).toBeDefined() + expect(subject).toHaveLength(1) + expect(subject[0].name).toEqual(name) + expect(subject[0].digest).toEqual({ sha256: expectedDigest }) + }) + }) + + describe('when a file glob is supplied', () => { + beforeEach(async () => { + process.env['INPUT_SUBJECT-PATH'] = path.join(dir, 'subject-*') + }) + + it('returns the multiple subjects', async () => { + const subjects = await subjectFromInputs() + + expect(subjects).toBeDefined() + expect(subjects).toHaveLength(3) + + /* eslint-disable-next-line github/array-foreach */ + subjects.forEach((subject, i) => { + expect(subject.name).toEqual(`${filename}-${i}`) + expect(subject.digest).toEqual({ sha256: expectedDigest }) + }) + }) + }) + }) +}) diff --git a/dist/index.js b/dist/index.js index 1190dca..a0552d2 100644 --- a/dist/index.js +++ b/dist/index.js @@ -65122,10 +65122,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.storePredicate = exports.predicateFromInputs = void 0; +exports.predicateFromInputs = void 0; const core = __importStar(__nccwpck_require__(2186)); const fs_1 = __importDefault(__nccwpck_require__(7147)); -const path = __importStar(__nccwpck_require__(1017)); // Returns the predicate specified by the action's inputs. The predicate value // may be specified as a path to a file or as a string. const predicateFromInputs = () => { @@ -65135,25 +65134,15 @@ const predicateFromInputs = () => { if (!predicatePath && !predicateStr) { throw new Error('One of predicate-path or predicate must be provided'); } + if (predicatePath && predicateStr) { + throw new Error('Only one of predicate-path or predicate may be provided'); + } const params = predicatePath ? fs_1.default.readFileSync(predicatePath, 'utf-8') : predicateStr; return { type: predicateType, params: JSON.parse(params) }; }; exports.predicateFromInputs = predicateFromInputs; -const storePredicate = (predicate) => { - // random tempfile - const basePath = process.env['RUNNER_TEMP']; - if (!basePath) { - throw new Error('Missing RUNNER_TEMP environment variable'); - } - const tmpDir = fs_1.default.mkdtempSync(path.join(basePath, path.sep)); - const tempFile = path.join(tmpDir, 'predicate.json'); - // write predicate to file - fs_1.default.writeFileSync(tempFile, JSON.stringify(predicate.params)); - return tempFile; -}; -exports.storePredicate = storePredicate; /***/ }), diff --git a/src/predicate.ts b/src/predicate.ts index 621771d..aedaf4a 100644 --- a/src/predicate.ts +++ b/src/predicate.ts @@ -1,6 +1,6 @@ import * as core from '@actions/core' import fs from 'fs' -import * as path from 'path' + import type { Predicate } from '@actions/attest' // Returns the predicate specified by the action's inputs. The predicate value @@ -14,25 +14,13 @@ export const predicateFromInputs = (): Predicate => { throw new Error('One of predicate-path or predicate must be provided') } + if (predicatePath && predicateStr) { + throw new Error('Only one of predicate-path or predicate may be provided') + } + const params = predicatePath ? fs.readFileSync(predicatePath, 'utf-8') : predicateStr return { type: predicateType, params: JSON.parse(params) } } - -export const storePredicate = (predicate: Predicate): string => { - // random tempfile - const basePath = process.env['RUNNER_TEMP'] - - if (!basePath) { - throw new Error('Missing RUNNER_TEMP environment variable') - } - - const tmpDir = fs.mkdtempSync(path.join(basePath, path.sep)) - const tempFile = path.join(tmpDir, 'predicate.json') - - // write predicate to file - fs.writeFileSync(tempFile, JSON.stringify(predicate.params)) - return tempFile -}