import { jest, describe, it, expect, beforeEach, afterAll, afterEach } from '@jest/globals'; import {fileURLToPath} from 'url'; import * as fs from 'fs'; import * as path from 'path'; import * as io from '@actions/io'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); jest.unstable_mockModule('@actions/exec', () => ({ exec: jest.fn() })); jest.unstable_mockModule('@actions/tool-cache', () => ({ downloadTool: jest.fn() })); const exec = await import('@actions/exec'); const tc = await import('@actions/tool-cache'); const gpg = await import('../src/gpg.js'); const tempDir = path.join(__dirname, 'runner', 'temp'); process.env['RUNNER_TEMP'] = tempDir; describe('gpg tests', () => { beforeEach(async () => { await io.rmRF(tempDir); await io.mkdirP(tempDir); jest.clearAllMocks(); (exec.exec as jest.Mock).mockResolvedValue(0); }); afterAll(async () => { try { await io.rmRF(tempDir); } catch { console.log('Failed to remove test directories'); } }); describe('toGpgPath', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', {value: originalPlatform}); }); it('returns path unchanged on non-Windows platforms', () => { Object.defineProperty(process, 'platform', {value: 'linux'}); expect(gpg.toGpgPath('/tmp/some/path')).toBe('/tmp/some/path'); expect(gpg.toGpgPath('D:\\a\\_temp\\file')).toBe('D:\\a\\_temp\\file'); }); it('converts Windows backslashes and drive letter to POSIX path on Windows', () => { Object.defineProperty(process, 'platform', {value: 'win32'}); expect(gpg.toGpgPath('D:\\a\\_temp\\gpg-home')).toBe( '/d/a/_temp/gpg-home' ); expect( gpg.toGpgPath('C:\\Users\\runner\\AppData\\Local\\Temp\\key.asc') ).toBe('/c/Users/runner/AppData/Local/Temp/key.asc'); }); it('handles uppercase and lowercase drive letters on Windows', () => { Object.defineProperty(process, 'platform', {value: 'win32'}); expect(gpg.toGpgPath('d:\\a\\_temp\\file')).toBe('/d/a/_temp/file'); }); }); describe('importKey', () => { it('imports private keys into a unique isolated GPG home', async () => { const privateKey = 'KEY CONTENTS'; let privateKeyFile = ''; (exec.exec as jest.Mock).mockImplementation( async (_command: string, _args: string[]) => { const [createdGpgHome] = fs.readdirSync(tempDir); privateKeyFile = path.join( tempDir, createdGpgHome, fs .readdirSync(path.join(tempDir, createdGpgHome)) .find(file => file.startsWith('private-key-')) ?? '' ); expect(fs.readFileSync(privateKeyFile, 'utf8')).toBe(privateKey); if (process.platform !== 'win32') { expect(fs.statSync(privateKeyFile).mode & 0o777).toBe(0o600); } return 0; } ); const gpgHome = await gpg.importKey(privateKey); expect(path.dirname(gpgHome)).toBe(tempDir); expect(path.basename(gpgHome).startsWith(gpg.GPG_HOME_PREFIX)).toBe(true); expect(fs.existsSync(gpgHome)).toBe(true); expect(fs.existsSync(privateKeyFile)).toBe(false); if (process.platform !== 'win32') { expect(fs.statSync(gpgHome).mode & 0o777).toBe(0o700); } expect(exec.exec).toHaveBeenCalledWith( 'gpg', [ '--homedir', gpg.toGpgPath(gpgHome), '--batch', '--import', gpg.toGpgPath(privateKeyFile) ], {silent: true} ); }); it('removes the private-key file and isolated home when import fails', async () => { let gpgHome = ''; let privateKeyFile = ''; (exec.exec as jest.Mock).mockImplementation( async (_command: string, _args: string[]) => { const [createdGpgHome] = fs.readdirSync(tempDir); gpgHome = path.join(tempDir, createdGpgHome); privateKeyFile = path.join( gpgHome, fs .readdirSync(gpgHome) .find(file => file.startsWith('private-key-')) ?? '' ); expect(fs.existsSync(privateKeyFile)).toBe(true); throw new Error('invalid key'); } ); await expect(gpg.importKey('INVALID KEY')).rejects.toThrow('invalid key'); expect(fs.existsSync(privateKeyFile)).toBe(false); expect(fs.existsSync(gpgHome)).toBe(false); }); it('imports multi-key input without parsing or deleting fingerprints', async () => { const privateKeys = 'KEY ONE\nKEY TWO'; (exec.exec as jest.Mock).mockImplementation( async (_command: string, _args: string[]) => { const [createdGpgHome] = fs.readdirSync(tempDir); const keyFile = fs .readdirSync(path.join(tempDir, createdGpgHome)) .find(file => file.startsWith('private-key-')); expect( fs.readFileSync( path.join(tempDir, createdGpgHome, keyFile ?? ''), 'utf8' ) ).toBe(privateKeys); return 0; } ); const gpgHome = await gpg.importKey(privateKeys); expect(gpgHome).toContain(gpg.GPG_HOME_PREFIX); expect(exec.exec).toHaveBeenCalledTimes(1); expect((exec.exec as jest.Mock).mock.calls[0][1]).not.toContain( '--delete-secret-and-public-key' ); }); it('uses a separate GPG home for each invocation', async () => { const firstGpgHome = await gpg.importKey('FIRST KEY'); const secondGpgHome = await gpg.importKey('SECOND KEY'); expect(firstGpgHome).not.toBe(secondGpgHome); expect(fs.existsSync(firstGpgHome)).toBe(true); expect(fs.existsSync(secondGpgHome)).toBe(true); }); }); describe('removeGpgHome', () => { it('removes only action-owned GPG homes and is idempotent', async () => { const gpgHome = await gpg.importKey('KEY CONTENTS'); const unrelatedGpgHome = path.join(tempDir, 'user-gpg-home'); fs.mkdirSync(unrelatedGpgHome); await gpg.removeGpgHome(gpgHome); await gpg.removeGpgHome(gpgHome); expect(exec.exec).toHaveBeenNthCalledWith( 2, 'gpgconf', ['--homedir', gpg.toGpgPath(gpgHome), '--kill', 'gpg-agent'], {silent: true, ignoreReturnCode: true} ); expect(exec.exec).toHaveBeenCalledTimes(2); expect(fs.existsSync(gpgHome)).toBe(false); expect(fs.existsSync(unrelatedGpgHome)).toBe(true); }); it('removes the GPG home when gpgconf is unavailable', async () => { const gpgHome = await gpg.importKey('KEY CONTENTS'); (exec.exec as jest.Mock).mockRejectedValueOnce( new Error('gpgconf not found') ); await gpg.removeGpgHome(gpgHome); expect(fs.existsSync(gpgHome)).toBe(false); }); it('refuses to remove a GPG home it does not own', async () => { const unrelatedGpgHome = path.join(tempDir, 'user-gpg-home'); fs.mkdirSync(unrelatedGpgHome, {recursive: true}); await expect(gpg.removeGpgHome(unrelatedGpgHome)).rejects.toThrow( 'Refusing to remove unexpected GPG home' ); expect(fs.existsSync(unrelatedGpgHome)).toBe(true); }); }); describe('verifyPackageSignature', () => { it('imports bundled key and verifies package', async () => { const publicKeyContent = '-----BEGIN PGP PUBLIC KEY BLOCK-----\ntest\n-----END PGP PUBLIC KEY BLOCK-----'; (tc.downloadTool as jest.Mock).mockResolvedValue( '/tmp/jdk.tar.gz.sig' ); await gpg.verifyPackageSignature( '/tmp/jdk.tar.gz', 'https://example.com/jdk.tar.gz.sig', publicKeyContent ); expect(tc.downloadTool).toHaveBeenCalledWith( 'https://example.com/jdk.tar.gz.sig' ); expect(exec.exec).toHaveBeenNthCalledWith( 1, 'gpg', [ '--homedir', expect.any(String), '--batch', '--import', expect.stringContaining('public-key.asc') ], expect.objectContaining({silent: true}) ); expect(exec.exec).toHaveBeenNthCalledWith( 2, 'gpg', [ '--homedir', expect.any(String), '--batch', '--verify', '/tmp/jdk.tar.gz.sig', '/tmp/jdk.tar.gz' ], expect.objectContaining({silent: true}) ); }); }); });