mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-12 06:10:42 -07:00
feat(extensions): implement cryptographic integrity verification for extension updates (#21772)
This commit is contained in:
@@ -16,21 +16,14 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../extension-manager.js';
|
||||
import { createTestMergedSettings } from '../settings.js';
|
||||
import { isWorkspaceTrusted } from '../trustedFolders.js';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = await importOriginal<any>();
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual.default,
|
||||
existsSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
},
|
||||
existsSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
@@ -38,6 +31,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
promises: {
|
||||
...actual.promises,
|
||||
mkdir: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
cp: vi.fn(),
|
||||
@@ -75,6 +69,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
Config: vi.fn().mockImplementation(() => ({
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(true),
|
||||
})),
|
||||
KeychainService: class {
|
||||
isAvailable = vi.fn().mockResolvedValue(true);
|
||||
getPassword = vi.fn().mockResolvedValue('test-key');
|
||||
setPassword = vi.fn().mockResolvedValue(undefined);
|
||||
},
|
||||
ExtensionIntegrityManager: class {
|
||||
verify = vi.fn().mockResolvedValue('verified');
|
||||
store = vi.fn().mockResolvedValue(undefined);
|
||||
},
|
||||
IntegrityDataStatus: {
|
||||
VERIFIED: 'verified',
|
||||
MISSING: 'missing',
|
||||
INVALID: 'invalid',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -134,13 +142,21 @@ describe('extensionUpdates', () => {
|
||||
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.readdir).mockResolvedValue([]);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(getMissingSettings).mockResolvedValue([]);
|
||||
|
||||
// Allow directories to exist by default to satisfy Config/WorkspaceContext checks
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as unknown as fs.Stats);
|
||||
vi.mocked(fs.lstatSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as unknown as fs.Stats);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
||||
|
||||
tempWorkspaceDir = '/mock/workspace';
|
||||
@@ -202,11 +218,10 @@ describe('extensionUpdates', () => {
|
||||
]);
|
||||
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
|
||||
// Mock loadExtension to return something so the method doesn't crash at the end
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({
|
||||
vi.spyOn(manager, 'loadExtension').mockResolvedValue({
|
||||
name: 'test-ext',
|
||||
version: '1.1.0',
|
||||
} as GeminiCLIExtension);
|
||||
} as unknown as GeminiCLIExtension);
|
||||
|
||||
// 4. Mock External Helpers
|
||||
// This is the key fix: we explicitly mock `getMissingSettings` to return
|
||||
@@ -235,5 +250,52 @@ describe('extensionUpdates', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should store integrity data after update', async () => {
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.1.0',
|
||||
};
|
||||
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: '/mock/source',
|
||||
type: 'local',
|
||||
};
|
||||
|
||||
const manager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
settings: createTestMergedSettings(),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
});
|
||||
|
||||
await manager.loadExtensions();
|
||||
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
|
||||
vi.spyOn(manager, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata,
|
||||
path: '/mock/extensions/test-ext',
|
||||
isActive: true,
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
|
||||
vi.spyOn(manager, 'loadExtension').mockResolvedValue({
|
||||
name: 'test-ext',
|
||||
version: '1.1.0',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
|
||||
const storeSpy = vi.spyOn(manager, 'storeExtensionIntegrity');
|
||||
|
||||
await manager.installOrUpdateExtension(installMetadata, previousConfig);
|
||||
|
||||
expect(storeSpy).toHaveBeenCalledWith('test-ext', installMetadata);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,16 @@ import {
|
||||
type ExtensionUpdateStatus,
|
||||
} from '../../ui/state/extensions.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
import { copyExtension, type ExtensionManager } from '../extension-manager.js';
|
||||
import { type ExtensionManager, copyExtension } from '../extension-manager.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import { loadInstallMetadata } from '../extension.js';
|
||||
import * as fs from 'node:fs';
|
||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type GeminiCLIExtension,
|
||||
type ExtensionInstallMetadata,
|
||||
IntegrityDataStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./storage.js', () => ({
|
||||
ExtensionStorage: {
|
||||
createTmpDir: vi.fn(),
|
||||
@@ -64,8 +67,18 @@ describe('Extension Update Logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExtensionManager = {
|
||||
loadExtensionConfig: vi.fn(),
|
||||
installOrUpdateExtension: vi.fn(),
|
||||
loadExtensionConfig: vi.fn().mockResolvedValue({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
installOrUpdateExtension: vi.fn().mockResolvedValue({
|
||||
...mockExtension,
|
||||
version: '1.1.0',
|
||||
}),
|
||||
verifyExtensionIntegrity: vi
|
||||
.fn()
|
||||
.mockResolvedValue(IntegrityDataStatus.VERIFIED),
|
||||
storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ExtensionManager;
|
||||
mockDispatch = vi.fn();
|
||||
|
||||
@@ -92,7 +105,7 @@ describe('Extension Update Logic', () => {
|
||||
it('should throw error and set state to ERROR if install metadata type is unknown', async () => {
|
||||
vi.mocked(loadInstallMetadata).mockReturnValue({
|
||||
type: undefined,
|
||||
} as unknown as import('@google/gemini-cli-core').ExtensionInstallMetadata);
|
||||
} as unknown as ExtensionInstallMetadata);
|
||||
|
||||
await expect(
|
||||
updateExtension(
|
||||
@@ -295,6 +308,77 @@ describe('Extension Update Logic', () => {
|
||||
});
|
||||
expect(fs.promises.rm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Integrity Verification', () => {
|
||||
it('should fail update with security alert if integrity is invalid', async () => {
|
||||
vi.mocked(
|
||||
mockExtensionManager.verifyExtensionIntegrity,
|
||||
).mockResolvedValue(IntegrityDataStatus.INVALID);
|
||||
|
||||
await expect(
|
||||
updateExtension(
|
||||
mockExtension,
|
||||
mockExtensionManager,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
mockDispatch,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Extension test-extension cannot be updated. Extension integrity cannot be verified.',
|
||||
);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: mockExtension.name,
|
||||
state: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should establish trust on first update if integrity data is missing', async () => {
|
||||
vi.mocked(
|
||||
mockExtensionManager.verifyExtensionIntegrity,
|
||||
).mockResolvedValue(IntegrityDataStatus.MISSING);
|
||||
|
||||
await updateExtension(
|
||||
mockExtension,
|
||||
mockExtensionManager,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
mockDispatch,
|
||||
);
|
||||
|
||||
// Verify updateExtension delegates to installOrUpdateExtension,
|
||||
// which is responsible for establishing trust internally.
|
||||
expect(
|
||||
mockExtensionManager.installOrUpdateExtension,
|
||||
).toHaveBeenCalled();
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: mockExtension.name,
|
||||
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if integrity manager throws', async () => {
|
||||
vi.mocked(
|
||||
mockExtensionManager.verifyExtensionIntegrity,
|
||||
).mockRejectedValue(new Error('Verification failed'));
|
||||
|
||||
await expect(
|
||||
updateExtension(
|
||||
mockExtension,
|
||||
mockExtensionManager,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
mockDispatch,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Extension test-extension cannot be updated. Verification failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAllUpdatableExtensions', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type GeminiCLIExtension,
|
||||
IntegrityDataStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { copyExtension, type ExtensionManager } from '../extension-manager.js';
|
||||
@@ -51,6 +52,26 @@ export async function updateExtension(
|
||||
`Extension ${extension.name} cannot be updated, type is unknown.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await extensionManager.verifyExtensionIntegrity(
|
||||
extension.name,
|
||||
installMetadata,
|
||||
);
|
||||
|
||||
if (status === IntegrityDataStatus.INVALID) {
|
||||
throw new Error('Extension integrity cannot be verified');
|
||||
}
|
||||
} catch (e) {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
throw new Error(
|
||||
`Extension ${extension.name} cannot be updated. ${getErrorMessage(e)}. To fix this, reinstall the extension.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (installMetadata?.type === 'link') {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
|
||||
Reference in New Issue
Block a user