feat(extensions): implement cryptographic integrity verification for extension updates (#21772)

This commit is contained in:
Emily Hedlund
2026-03-16 15:01:52 -04:00
committed by GitHub
parent d43ec6c8f3
commit 05fda0cf01
18 changed files with 1271 additions and 103 deletions
@@ -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);
});
});
});