mirror of
https://github.com/actions/setup-java.git
synced 2026-08-06 17:12:58 +00:00
Import Maven signing keys into an isolated GPG home (#1214)
* Isolate Maven signing keys Import signing keys into an action-owned temporary GPG home, export GNUPGHOME, and remove the owned directory in the post action. Cover import failure, multiple keys and invocations, unrelated keyrings, missing state, and Windows path conversion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix cleanup state assertion Account for isolated GPG-home cleanup when cache saving is disabled. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Update generated action bundles Apply repository formatting and commit the setup and cleanup bundles produced by the validated Node 24 build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address isolated GPG home review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Bruno Borges <brborges@microsoft.com>
This commit is contained in:
+64
-1
@@ -41,10 +41,18 @@ jest.unstable_mockModule('@actions/core', () => ({
|
||||
toPosixPath: jest.fn((p: string) => p)
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('../src/gpg.js', () => ({
|
||||
importKey: jest.fn(),
|
||||
removeGpgHome: jest.fn(),
|
||||
toGpgPath: jest.fn()
|
||||
}));
|
||||
|
||||
// Dynamic imports after mocking
|
||||
const core = await import('@actions/core');
|
||||
const gpg = await import('../src/gpg.js');
|
||||
const auth = await import('../src/auth.js');
|
||||
const {M2_DIR, MVN_SETTINGS_FILE} = await import('../src/constants.js');
|
||||
const {M2_DIR, MVN_SETTINGS_FILE, STATE_GPG_HOME} =
|
||||
await import('../src/constants.js');
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const m2Dir = path.join(__dirname, M2_DIR);
|
||||
@@ -60,8 +68,17 @@ describe('auth tests', () => {
|
||||
spyOSHomedir.mockReturnValue(__dirname);
|
||||
spyInfo = core.info as jest.Mock;
|
||||
spyInfo.mockImplementation(() => null);
|
||||
(gpg.toGpgPath as jest.Mock<any>).mockImplementation((p: string) => p);
|
||||
}, 300000);
|
||||
|
||||
afterEach(() => {
|
||||
(core.getInput as jest.Mock).mockReset();
|
||||
(core.exportVariable as jest.Mock).mockReset();
|
||||
(gpg.importKey as jest.Mock).mockReset();
|
||||
(gpg.removeGpgHome as jest.Mock).mockReset();
|
||||
(gpg.toGpgPath as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await io.rmRF(m2Dir);
|
||||
@@ -144,6 +161,52 @@ describe('auth tests', () => {
|
||||
);
|
||||
}, 100000);
|
||||
|
||||
it('exports a GPG-compatible path and persists the native GPG home', async () => {
|
||||
const gpgHome = 'D:\\a\\_temp\\setup-java-gpg-1';
|
||||
const exportedGpgHome = '/d/a/_temp/setup-java-gpg-1';
|
||||
(gpg.importKey as jest.Mock<any>).mockResolvedValue(gpgHome);
|
||||
(gpg.toGpgPath as jest.Mock<any>).mockReturnValue(exportedGpgHome);
|
||||
(core.getInput as jest.Mock<any>).mockImplementation((name: string) => {
|
||||
const inputs: Record<string, string> = {
|
||||
'server-id': 'packages',
|
||||
'server-username-env-var': 'USERNAME',
|
||||
'server-password-env-var': 'PASSWORD',
|
||||
'settings-path': m2Dir,
|
||||
'gpg-private-key': 'KEY ONE\nKEY TWO'
|
||||
};
|
||||
return inputs[name] ?? '';
|
||||
});
|
||||
|
||||
await auth.configureAuthentication();
|
||||
|
||||
expect(gpg.importKey).toHaveBeenCalledWith('KEY ONE\nKEY TWO');
|
||||
expect(core.saveState).toHaveBeenCalledWith(STATE_GPG_HOME, gpgHome);
|
||||
expect(gpg.toGpgPath).toHaveBeenCalledWith(gpgHome);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith(
|
||||
'GNUPGHOME',
|
||||
exportedGpgHome
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the isolated GPG home when environment export fails', async () => {
|
||||
const gpgHome = path.join(__dirname, 'runner', 'temp', 'setup-java-gpg-2');
|
||||
(gpg.importKey as jest.Mock<any>).mockResolvedValue(gpgHome);
|
||||
(core.exportVariable as jest.Mock<any>).mockImplementation(() => {
|
||||
throw new Error('environment file unavailable');
|
||||
});
|
||||
(core.getInput as jest.Mock<any>).mockImplementation((name: string) => {
|
||||
if (name === 'gpg-private-key') return 'KEY CONTENTS';
|
||||
if (name === 'settings-path') return m2Dir;
|
||||
return '';
|
||||
});
|
||||
|
||||
await expect(auth.configureAuthentication()).rejects.toThrow(
|
||||
'environment file unavailable'
|
||||
);
|
||||
|
||||
expect(gpg.removeGpgHome).toHaveBeenCalledWith(gpgHome);
|
||||
});
|
||||
|
||||
it('overwrites existing settings.xml files', async () => {
|
||||
const id = 'packages';
|
||||
const username = 'USERNAME';
|
||||
|
||||
@@ -63,6 +63,8 @@ const core = await import('@actions/core');
|
||||
const cache = await import('@actions/cache');
|
||||
const {run: cleanup} = await import('../src/cleanup-java.js');
|
||||
const util = await import('../src/util.js');
|
||||
const constants = await import('../src/constants.js');
|
||||
const {GPG_HOME_PREFIX} = await import('../src/gpg.js');
|
||||
const {registerJdk, buildJdkCacheKey} = await import('../src/jdk-cache.js');
|
||||
|
||||
const jdkTempRoots: string[] = [];
|
||||
@@ -114,11 +116,65 @@ describe('cleanup', () => {
|
||||
(core.getInput as jest.Mock<any>).mockImplementation((name: string) => {
|
||||
return name === 'cache' ? 'gradle' : '';
|
||||
});
|
||||
|
||||
await cleanup();
|
||||
expect(spyCacheSave).toHaveBeenCalled();
|
||||
expect(spyWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the isolated GPG home without touching unrelated key material', async () => {
|
||||
const tempDir = util.getTempDir();
|
||||
fs.mkdirSync(tempDir, {recursive: true});
|
||||
const gpgHome = fs.mkdtempSync(path.join(tempDir, GPG_HOME_PREFIX));
|
||||
const unrelatedGpgHome = fs.mkdtempSync(
|
||||
path.join(tempDir, 'user-gpg-home-')
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(unrelatedGpgHome, 'private.key'),
|
||||
'pre-existing'
|
||||
);
|
||||
(core.getInput as jest.Mock<any>).mockReturnValue('');
|
||||
(core.getState as jest.Mock<any>).mockImplementation((name: string) =>
|
||||
name === constants.STATE_GPG_HOME ? gpgHome : ''
|
||||
);
|
||||
|
||||
await cleanup();
|
||||
|
||||
expect(fs.existsSync(gpgHome)).toBe(false);
|
||||
expect(
|
||||
fs.readFileSync(path.join(unrelatedGpgHome, 'private.key'), 'utf8')
|
||||
).toBe('pre-existing');
|
||||
fs.rmSync(unrelatedGpgHome, {recursive: true, force: true});
|
||||
});
|
||||
|
||||
it('makes repeated cleanup of the same GPG home idempotent', async () => {
|
||||
const tempDir = util.getTempDir();
|
||||
fs.mkdirSync(tempDir, {recursive: true});
|
||||
const gpgHome = fs.mkdtempSync(path.join(tempDir, GPG_HOME_PREFIX));
|
||||
(core.getInput as jest.Mock<any>).mockReturnValue('');
|
||||
(core.getState as jest.Mock<any>).mockImplementation((name: string) =>
|
||||
name === constants.STATE_GPG_HOME ? gpgHome : ''
|
||||
);
|
||||
|
||||
await cleanup();
|
||||
await cleanup();
|
||||
|
||||
expect(fs.existsSync(gpgHome)).toBe(false);
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips GPG cleanup when no home was persisted', async () => {
|
||||
(core.getInput as jest.Mock<any>).mockReturnValue('');
|
||||
(core.getState as jest.Mock<any>).mockReturnValue('');
|
||||
|
||||
await cleanup();
|
||||
|
||||
expect(spyInfo).not.toHaveBeenCalledWith(
|
||||
'Removing private key from isolated GPG home'
|
||||
);
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fail even though the save process throws error', async () => {
|
||||
spyCacheSave.mockImplementation((paths: string[], key: string) =>
|
||||
Promise.reject(new Error('Unexpected error'))
|
||||
@@ -148,7 +204,8 @@ describe('cleanup', () => {
|
||||
await cleanup();
|
||||
|
||||
expect(spyCacheSave).not.toHaveBeenCalled();
|
||||
expect(core.getState).not.toHaveBeenCalled();
|
||||
expect(core.getState).toHaveBeenCalledTimes(1);
|
||||
expect(core.getState).toHaveBeenCalledWith(constants.STATE_GPG_HOME);
|
||||
expect(spyInfo).toHaveBeenCalledWith(
|
||||
'Cache saving is skipped because cache-read-only is enabled.'
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ jest.unstable_mockModule('../../src/util.js', () => ({
|
||||
|
||||
jest.unstable_mockModule('../../src/gpg.js', () => ({
|
||||
importKey: jest.fn(),
|
||||
deleteKey: jest.fn(),
|
||||
removeGpgHome: jest.fn(),
|
||||
verifyPackageSignature: jest.fn()
|
||||
}));
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ jest.unstable_mockModule('../../src/util.js', () => ({
|
||||
|
||||
jest.unstable_mockModule('../../src/gpg.js', () => ({
|
||||
importKey: jest.fn(),
|
||||
deleteKey: jest.fn(),
|
||||
removeGpgHome: jest.fn(),
|
||||
verifyPackageSignature: jest.fn()
|
||||
}));
|
||||
|
||||
|
||||
+177
-54
@@ -8,6 +8,7 @@ import {
|
||||
afterEach
|
||||
} from '@jest/globals';
|
||||
import {fileURLToPath} from 'url';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as io from '@actions/io';
|
||||
|
||||
@@ -30,7 +31,10 @@ 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<any>).mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -71,74 +75,193 @@ describe('gpg tests', () => {
|
||||
});
|
||||
|
||||
describe('importKey', () => {
|
||||
it('attempts to import private key and returns null key id on failure', async () => {
|
||||
it('imports private keys into a unique isolated GPG home', async () => {
|
||||
const privateKey = 'KEY CONTENTS';
|
||||
const keyId = await gpg.importKey(privateKey);
|
||||
let privateKeyFile = '';
|
||||
(exec.exec as jest.Mock<any>).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;
|
||||
}
|
||||
);
|
||||
|
||||
expect(keyId).toBeNull();
|
||||
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',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
[
|
||||
'--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<any>).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<any>).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('deleteKey', () => {
|
||||
it('deletes private key', async () => {
|
||||
const keyId = 'asdfhjkl';
|
||||
await gpg.deleteKey(keyId);
|
||||
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);
|
||||
|
||||
expect(exec.exec).toHaveBeenCalledWith(
|
||||
'gpg',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
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);
|
||||
});
|
||||
|
||||
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<any>).mockResolvedValue(
|
||||
'/tmp/jdk.tar.gz.sig'
|
||||
);
|
||||
await gpg.verifyPackageSignature(
|
||||
'/tmp/jdk.tar.gz',
|
||||
'https://example.com/jdk.tar.gz.sig',
|
||||
publicKeyContent
|
||||
);
|
||||
it('removes the GPG home when gpgconf is unavailable', async () => {
|
||||
const gpgHome = await gpg.importKey('KEY CONTENTS');
|
||||
(exec.exec as jest.Mock<any>).mockRejectedValueOnce(
|
||||
new Error('gpgconf not found')
|
||||
);
|
||||
|
||||
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})
|
||||
);
|
||||
});
|
||||
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<any>).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})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user