mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 20:30:53 -07:00
fix: enforce folder trust for workspace settings, skills, and context (#17596)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -21,6 +21,17 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
return {
|
||||
...actualFs,
|
||||
existsSync: vi.fn(actualFs.existsSync),
|
||||
readFileSync: vi.fn(actualFs.readFileSync),
|
||||
writeFileSync: vi.fn(actualFs.writeFileSync),
|
||||
mkdirSync: vi.fn(actualFs.mkdirSync),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
@@ -30,6 +41,14 @@ vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi.fn(() => ({
|
||||
isTrusted: true,
|
||||
source: undefined,
|
||||
})),
|
||||
isFolderTrustEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('mcp remove command', () => {
|
||||
describe('unit tests with mocks', () => {
|
||||
let parser: Argv;
|
||||
|
||||
@@ -105,7 +105,7 @@ vi.mock('fs', async (importOriginal) => {
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: (p: string) => p,
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -119,9 +119,11 @@ const mockCoreEvents = vi.hoisted(() => ({
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const os = await import('node:os');
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: mockCoreEvents,
|
||||
homedir: vi.fn(() => os.homedir()),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1460,6 +1462,44 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => {
|
||||
const mockHomeDir = '/mock/home/user';
|
||||
const mockSymlinkDir = '/mock/symlink/to/home';
|
||||
const mockWorkspaceSettingsPath = path.join(
|
||||
mockSymlinkDir,
|
||||
GEMINI_DIR,
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
vi.mocked(osActual.homedir).mockReturnValue(mockHomeDir);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
|
||||
const pStr = p.toString();
|
||||
const resolved = path.resolve(pStr);
|
||||
if (
|
||||
resolved === path.resolve(mockSymlinkDir) ||
|
||||
resolved === path.resolve(mockHomeDir)
|
||||
) {
|
||||
return mockHomeDir;
|
||||
}
|
||||
return pStr;
|
||||
});
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: string) =>
|
||||
// Only return true for workspace settings path to see if it gets loaded
|
||||
p === mockWorkspaceSettingsPath,
|
||||
);
|
||||
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('excludedProjectEnvVars integration', () => {
|
||||
@@ -2373,3 +2413,119 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoadedSettings Isolation and Serializability', () => {
|
||||
let loadedSettings: LoadedSettings;
|
||||
|
||||
interface TestData {
|
||||
a: {
|
||||
b: number;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Create a minimal LoadedSettings instance
|
||||
const emptyScope = {
|
||||
path: '/mock/settings.json',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
|
||||
loadedSettings = new LoadedSettings(
|
||||
emptyScope, // system
|
||||
emptyScope, // systemDefaults
|
||||
{ ...emptyScope }, // user
|
||||
emptyScope, // workspace
|
||||
true, // isTrusted
|
||||
);
|
||||
});
|
||||
|
||||
describe('setValue Isolation', () => {
|
||||
it('should isolate state between settings and originalSettings', () => {
|
||||
const complexValue: TestData = { a: { b: 1 } };
|
||||
loadedSettings.setValue(SettingScope.User, 'test', complexValue);
|
||||
|
||||
const userSettings = loadedSettings.forScope(SettingScope.User);
|
||||
const settingsValue = (userSettings.settings as Record<string, unknown>)[
|
||||
'test'
|
||||
] as TestData;
|
||||
const originalValue = (
|
||||
userSettings.originalSettings as Record<string, unknown>
|
||||
)['test'] as TestData;
|
||||
|
||||
// Verify they are equal but different references
|
||||
expect(settingsValue).toEqual(complexValue);
|
||||
expect(originalValue).toEqual(complexValue);
|
||||
expect(settingsValue).not.toBe(complexValue);
|
||||
expect(originalValue).not.toBe(complexValue);
|
||||
expect(settingsValue).not.toBe(originalValue);
|
||||
|
||||
// Modify the in-memory setting object
|
||||
settingsValue.a.b = 2;
|
||||
|
||||
// originalSettings should NOT be affected
|
||||
expect(originalValue.a.b).toBe(1);
|
||||
});
|
||||
|
||||
it('should not share references between settings and originalSettings (original servers test)', () => {
|
||||
const mcpServers = {
|
||||
'test-server': { command: 'echo' },
|
||||
};
|
||||
|
||||
loadedSettings.setValue(SettingScope.User, 'mcpServers', mcpServers);
|
||||
|
||||
// Modify the original object
|
||||
delete (mcpServers as Record<string, unknown>)['test-server'];
|
||||
|
||||
// The settings in LoadedSettings should still have the server
|
||||
const userSettings = loadedSettings.forScope(SettingScope.User);
|
||||
expect(
|
||||
(userSettings.settings.mcpServers as Record<string, unknown>)[
|
||||
'test-server'
|
||||
],
|
||||
).toBeDefined();
|
||||
expect(
|
||||
(userSettings.originalSettings.mcpServers as Record<string, unknown>)[
|
||||
'test-server'
|
||||
],
|
||||
).toBeDefined();
|
||||
|
||||
// They should also be different objects from each other
|
||||
expect(userSettings.settings.mcpServers).not.toBe(
|
||||
userSettings.originalSettings.mcpServers,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setValue Serializability', () => {
|
||||
it('should preserve Map/Set types (via structuredClone)', () => {
|
||||
const mapValue = { myMap: new Map([['key', 'value']]) };
|
||||
loadedSettings.setValue(SettingScope.User, 'test', mapValue);
|
||||
|
||||
const userSettings = loadedSettings.forScope(SettingScope.User);
|
||||
const settingsValue = (userSettings.settings as Record<string, unknown>)[
|
||||
'test'
|
||||
] as { myMap: Map<string, string> };
|
||||
|
||||
// Map is preserved by structuredClone
|
||||
expect(settingsValue.myMap).toBeInstanceOf(Map);
|
||||
expect(settingsValue.myMap.get('key')).toBe('value');
|
||||
|
||||
// But it should be a different reference
|
||||
expect(settingsValue.myMap).not.toBe(mapValue.myMap);
|
||||
});
|
||||
|
||||
it('should handle circular references (structuredClone supports them, but deepMerge may not)', () => {
|
||||
const circular: Record<string, unknown> = { a: 1 };
|
||||
circular['self'] = circular;
|
||||
|
||||
// structuredClone(circular) works, but LoadedSettings.setValue calls
|
||||
// computeMergedSettings() -> customDeepMerge() which blows up on circularity.
|
||||
expect(() => {
|
||||
loadedSettings.setValue(SettingScope.User, 'test', circular);
|
||||
}).toThrow(/Maximum call stack size exceeded/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,8 +277,11 @@ export class LoadedSettings {
|
||||
this.system = system;
|
||||
this.systemDefaults = systemDefaults;
|
||||
this.user = user;
|
||||
this.workspace = workspace;
|
||||
this._workspaceFile = workspace;
|
||||
this.isTrusted = isTrusted;
|
||||
this.workspace = isTrusted
|
||||
? workspace
|
||||
: this.createEmptyWorkspace(workspace);
|
||||
this.errors = errors;
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
@@ -286,10 +289,11 @@ export class LoadedSettings {
|
||||
readonly system: SettingsFile;
|
||||
readonly systemDefaults: SettingsFile;
|
||||
readonly user: SettingsFile;
|
||||
readonly workspace: SettingsFile;
|
||||
readonly isTrusted: boolean;
|
||||
workspace: SettingsFile;
|
||||
isTrusted: boolean;
|
||||
readonly errors: SettingsError[];
|
||||
|
||||
private _workspaceFile: SettingsFile;
|
||||
private _merged: MergedSettings;
|
||||
private _remoteAdminSettings: Partial<Settings> | undefined;
|
||||
|
||||
@@ -297,6 +301,26 @@ export class LoadedSettings {
|
||||
return this._merged;
|
||||
}
|
||||
|
||||
setTrusted(isTrusted: boolean): void {
|
||||
if (this.isTrusted === isTrusted) {
|
||||
return;
|
||||
}
|
||||
this.isTrusted = isTrusted;
|
||||
this.workspace = isTrusted
|
||||
? this._workspaceFile
|
||||
: this.createEmptyWorkspace(this._workspaceFile);
|
||||
this._merged = this.computeMergedSettings();
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
|
||||
private createEmptyWorkspace(workspace: SettingsFile): SettingsFile {
|
||||
return {
|
||||
...workspace,
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
};
|
||||
}
|
||||
|
||||
private computeMergedSettings(): MergedSettings {
|
||||
const merged = mergeSettings(
|
||||
this.system.settings,
|
||||
@@ -341,8 +365,21 @@ export class LoadedSettings {
|
||||
|
||||
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
setNestedProperty(settingsFile.settings, key, value);
|
||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||
|
||||
// Clone value to prevent reference sharing between settings and originalSettings
|
||||
const valueToSet =
|
||||
typeof value === 'object' && value !== null
|
||||
? structuredClone(value)
|
||||
: value;
|
||||
|
||||
setNestedProperty(settingsFile.settings, key, valueToSet);
|
||||
// Use a fresh clone for originalSettings to ensure total independence
|
||||
setNestedProperty(
|
||||
settingsFile.originalSettings,
|
||||
key,
|
||||
structuredClone(valueToSet),
|
||||
);
|
||||
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
coreEvents.emitSettingsChanged();
|
||||
@@ -592,9 +629,10 @@ export function loadSettings(
|
||||
// For the initial trust check, we can only use user and system settings.
|
||||
const initialTrustCheckSettings = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
systemSettings,
|
||||
getDefaultsFromSchema(),
|
||||
systemDefaultSettings,
|
||||
userSettings,
|
||||
systemSettings,
|
||||
);
|
||||
const isTrusted =
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir)
|
||||
@@ -672,57 +710,55 @@ export function migrateDeprecatedSettings(
|
||||
removeDeprecated = false,
|
||||
): boolean {
|
||||
let anyModified = false;
|
||||
|
||||
const migrateBoolean = (
|
||||
settings: Record<string, unknown>,
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
): boolean => {
|
||||
let modified = false;
|
||||
const oldValue = settings[oldKey];
|
||||
const newValue = settings[newKey];
|
||||
|
||||
if (typeof oldValue === 'boolean') {
|
||||
if (typeof newValue === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete settings[oldKey];
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
// Only old exists, migrate to new (inverted)
|
||||
settings[newKey] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete settings[oldKey];
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
};
|
||||
|
||||
const processScope = (scope: LoadableSettingScope) => {
|
||||
const settings = loadedSettings.forScope(scope).settings;
|
||||
|
||||
// Migrate inverted boolean settings (disableX -> enableX)
|
||||
// These settings were renamed and their boolean logic inverted
|
||||
// Migrate general settings
|
||||
const generalSettings = settings.general as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
const contextSettings = settings.context as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
// Migrate general settings (disableAutoUpdate, disableUpdateNag)
|
||||
if (generalSettings) {
|
||||
const newGeneral: Record<string, unknown> = { ...generalSettings };
|
||||
const newGeneral = { ...generalSettings };
|
||||
let modified = false;
|
||||
|
||||
if (typeof newGeneral['disableAutoUpdate'] === 'boolean') {
|
||||
if (typeof newGeneral['enableAutoUpdate'] === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableAutoUpdate'];
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = newGeneral['disableAutoUpdate'];
|
||||
newGeneral['enableAutoUpdate'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableAutoUpdate'];
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof newGeneral['disableUpdateNag'] === 'boolean') {
|
||||
if (typeof newGeneral['enableAutoUpdateNotification'] === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableUpdateNag'];
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = newGeneral['disableUpdateNag'];
|
||||
newGeneral['enableAutoUpdateNotification'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableUpdateNag'];
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
modified =
|
||||
migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') ||
|
||||
modified;
|
||||
modified =
|
||||
migrateBoolean(
|
||||
newGeneral,
|
||||
'disableUpdateNag',
|
||||
'enableAutoUpdateNotification',
|
||||
) || modified;
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
@@ -731,94 +767,63 @@ export function migrateDeprecatedSettings(
|
||||
}
|
||||
|
||||
// Migrate ui settings
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
if (uiSettings) {
|
||||
const newUi: Record<string, unknown> = { ...uiSettings };
|
||||
let modified = false;
|
||||
|
||||
// Migrate ui.accessibility.disableLoadingPhrases -> ui.accessibility.enableLoadingPhrases
|
||||
const newUi = { ...uiSettings };
|
||||
const accessibilitySettings = newUi['accessibility'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (
|
||||
accessibilitySettings &&
|
||||
typeof accessibilitySettings['disableLoadingPhrases'] === 'boolean'
|
||||
) {
|
||||
const newAccessibility: Record<string, unknown> = {
|
||||
...accessibilitySettings,
|
||||
};
|
||||
if (
|
||||
typeof accessibilitySettings['enableLoadingPhrases'] === 'boolean'
|
||||
) {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newAccessibility['disableLoadingPhrases'];
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = accessibilitySettings['disableLoadingPhrases'];
|
||||
newAccessibility['enableLoadingPhrases'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newAccessibility['disableLoadingPhrases'];
|
||||
}
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
anyModified = true;
|
||||
if (accessibilitySettings) {
|
||||
const newAccessibility = { ...accessibilitySettings };
|
||||
if (
|
||||
migrateBoolean(
|
||||
newAccessibility,
|
||||
'disableLoadingPhrases',
|
||||
'enableLoadingPhrases',
|
||||
)
|
||||
) {
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate context settings
|
||||
const contextSettings = settings.context as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (contextSettings) {
|
||||
const newContext: Record<string, unknown> = { ...contextSettings };
|
||||
let modified = false;
|
||||
|
||||
// Migrate context.fileFiltering.disableFuzzySearch -> context.fileFiltering.enableFuzzySearch
|
||||
const newContext = { ...contextSettings };
|
||||
const fileFilteringSettings = newContext['fileFiltering'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (
|
||||
fileFilteringSettings &&
|
||||
typeof fileFilteringSettings['disableFuzzySearch'] === 'boolean'
|
||||
) {
|
||||
const newFileFiltering: Record<string, unknown> = {
|
||||
...fileFilteringSettings,
|
||||
};
|
||||
if (typeof fileFilteringSettings['enableFuzzySearch'] === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newFileFiltering['disableFuzzySearch'];
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = fileFilteringSettings['disableFuzzySearch'];
|
||||
newFileFiltering['enableFuzzySearch'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newFileFiltering['disableFuzzySearch'];
|
||||
}
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
anyModified = true;
|
||||
if (fileFilteringSettings) {
|
||||
const newFileFiltering = { ...fileFilteringSettings };
|
||||
if (
|
||||
migrateBoolean(
|
||||
newFileFiltering,
|
||||
'disableFuzzySearch',
|
||||
'enableFuzzySearch',
|
||||
)
|
||||
) {
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
anyModified ||= migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
);
|
||||
anyModified =
|
||||
migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
) || anyModified;
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('fs', async (importOriginal) => {
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
};
|
||||
});
|
||||
vi.mock('strip-json-comments', () => ({
|
||||
@@ -60,22 +61,23 @@ vi.mock('strip-json-comments', () => ({
|
||||
}));
|
||||
|
||||
describe('Trusted Folders Loading', () => {
|
||||
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
|
||||
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
|
||||
let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.resetAllMocks();
|
||||
mockFsExistsSync = vi.mocked(fs.existsSync);
|
||||
mockStripJsonComments = vi.mocked(stripJsonComments);
|
||||
mockFsWriteFileSync = vi.mocked(fs.writeFileSync);
|
||||
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
|
||||
(mockStripJsonComments as unknown as Mock).mockImplementation(
|
||||
(jsonString: string) => jsonString,
|
||||
);
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false);
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -90,13 +92,16 @@ describe('Trusted Folders Loading', () => {
|
||||
|
||||
describe('isPathTrusted', () => {
|
||||
function setup({ config = {} as Record<string, TrustLevel> } = {}) {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath())
|
||||
return JSON.stringify(config);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) return JSON.stringify(config);
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
@@ -124,26 +129,62 @@ describe('Trusted Folders Loading', () => {
|
||||
expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true);
|
||||
|
||||
// No explicit rule covers this file
|
||||
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(false);
|
||||
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(false);
|
||||
expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('prioritizes the longest matching path (precedence)', () => {
|
||||
const { folders } = setup({
|
||||
config: {
|
||||
'/a': TrustLevel.TRUST_FOLDER,
|
||||
'/a/b': TrustLevel.DO_NOT_TRUST,
|
||||
'/a/b/c': TrustLevel.TRUST_FOLDER,
|
||||
'/parent/trustme': TrustLevel.TRUST_PARENT, // effective path is /parent
|
||||
'/parent/trustme/butnotthis': TrustLevel.DO_NOT_TRUST,
|
||||
},
|
||||
});
|
||||
|
||||
// /a/b/c/d matches /a (len 2), /a/b (len 4), /a/b/c (len 6).
|
||||
// /a/b/c wins (TRUST_FOLDER).
|
||||
expect(folders.isPathTrusted('/a/b/c/d')).toBe(true);
|
||||
|
||||
// /a/b/x matches /a (len 2), /a/b (len 4).
|
||||
// /a/b wins (DO_NOT_TRUST).
|
||||
expect(folders.isPathTrusted('/a/b/x')).toBe(false);
|
||||
|
||||
// /a/x matches /a (len 2).
|
||||
// /a wins (TRUST_FOLDER).
|
||||
expect(folders.isPathTrusted('/a/x')).toBe(true);
|
||||
|
||||
// Overlap with TRUST_PARENT
|
||||
// /parent/trustme/butnotthis/file matches:
|
||||
// - /parent/trustme (len 15, TRUST_PARENT -> effective /parent)
|
||||
// - /parent/trustme/butnotthis (len 26, DO_NOT_TRUST)
|
||||
// /parent/trustme/butnotthis wins.
|
||||
expect(folders.isPathTrusted('/parent/trustme/butnotthis/file')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// /parent/other matches /parent/trustme (len 15, effective /parent)
|
||||
expect(folders.isPathTrusted('/parent/other')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should load user rules if only user file exists', () => {
|
||||
const userPath = getTrustedFoldersPath();
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === userPath,
|
||||
);
|
||||
const userContent = {
|
||||
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === userPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === userPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
@@ -154,11 +195,15 @@ describe('Trusted Folders Loading', () => {
|
||||
|
||||
it('should handle JSON parsing errors gracefully', () => {
|
||||
const userPath = getTrustedFoldersPath();
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === userPath) return 'invalid json';
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === userPath,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === userPath) return 'invalid json';
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([]);
|
||||
@@ -171,14 +216,18 @@ describe('Trusted Folders Loading', () => {
|
||||
const customPath = '/custom/path/to/trusted_folders.json';
|
||||
process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath;
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === customPath);
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === customPath,
|
||||
);
|
||||
const userContent = {
|
||||
'/user/folder/from/env': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === customPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === customPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
@@ -221,14 +270,16 @@ describe('isWorkspaceTrusted', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
(p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -241,12 +292,14 @@ describe('isWorkspaceTrusted', () => {
|
||||
it('should throw a fatal error if the config is malformed', () => {
|
||||
mockCwd = '/home/user/projectA';
|
||||
// This mock needs to be specific to this test to override the one in beforeEach
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
|
||||
/Please fix the configuration file/,
|
||||
@@ -255,12 +308,14 @@ describe('isWorkspaceTrusted', () => {
|
||||
|
||||
it('should throw a fatal error if the config is not a JSON object', () => {
|
||||
mockCwd = '/home/user/projectA';
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return 'null';
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return 'null';
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
|
||||
/not a valid JSON object/,
|
||||
@@ -303,10 +358,10 @@ describe('isWorkspaceTrusted', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for a child of an untrusted folder', () => {
|
||||
it('should return false for a child of an untrusted folder', () => {
|
||||
mockCwd = '/home/user/untrusted/src';
|
||||
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined when no rules match', () => {
|
||||
@@ -316,12 +371,12 @@ describe('isWorkspaceTrusted', () => {
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prioritize trust over distrust', () => {
|
||||
it('should prioritize specific distrust over parent trust', () => {
|
||||
mockCwd = '/home/user/projectA/untrusted';
|
||||
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
|
||||
mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
@@ -351,6 +406,19 @@ describe('isWorkspaceTrusted', () => {
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted with IDE override', () => {
|
||||
const mockCwd = '/home/user/projectA';
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString().endsWith('trustedFolders.json') ? false : true,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ideContextStore.clear();
|
||||
@@ -390,10 +458,15 @@ describe('isWorkspaceTrusted with IDE override', () => {
|
||||
});
|
||||
|
||||
it('should fall back to config when ideTrust is undefined', () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }),
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p) =>
|
||||
p === getTrustedFoldersPath() || p === mockCwd ? true : false,
|
||||
);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify({ [mockCwd]: TrustLevel.TRUST_FOLDER });
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
@@ -419,8 +492,11 @@ describe('isWorkspaceTrusted with IDE override', () => {
|
||||
describe('Trusted Folders Caching', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('{}');
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -454,14 +530,20 @@ describe('invalid trust levels', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
(p: fs.PathLike) =>
|
||||
p.toString() === getTrustedFoldersPath() || p.toString() === mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -495,3 +577,179 @@ describe('invalid trust levels', () => {
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trusted Folders realpath caching', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should only call fs.realpathSync once for the same path', () => {
|
||||
const mockPath = '/some/path';
|
||||
const mockRealPath = '/real/path';
|
||||
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
const realpathSpy = vi
|
||||
.spyOn(fs, 'realpathSync')
|
||||
.mockReturnValue(mockRealPath);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({
|
||||
[mockPath]: TrustLevel.TRUST_FOLDER,
|
||||
'/another/path': TrustLevel.TRUST_FOLDER,
|
||||
}),
|
||||
);
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
// Call isPathTrusted multiple times with the same path
|
||||
folders.isPathTrusted(mockPath);
|
||||
folders.isPathTrusted(mockPath);
|
||||
folders.isPathTrusted(mockPath);
|
||||
|
||||
// fs.realpathSync should only be called once for mockPath (at the start of isPathTrusted)
|
||||
// And once for each rule in the config (if they are different)
|
||||
|
||||
// Let's check calls for mockPath
|
||||
const mockPathCalls = realpathSpy.mock.calls.filter(
|
||||
(call) => call[0] === mockPath,
|
||||
);
|
||||
|
||||
expect(mockPathCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should cache results for rule paths in the loop', () => {
|
||||
const rulePath = '/rule/path';
|
||||
const locationPath = '/location/path';
|
||||
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
const realpathSpy = vi
|
||||
.spyOn(fs, 'realpathSync')
|
||||
.mockImplementation((p: fs.PathLike) => p.toString()); // identity for simplicity
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({
|
||||
[rulePath]: TrustLevel.TRUST_FOLDER,
|
||||
}),
|
||||
);
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
// First call
|
||||
folders.isPathTrusted(locationPath);
|
||||
const firstCallCount = realpathSpy.mock.calls.length;
|
||||
expect(firstCallCount).toBe(2); // locationPath and rulePath
|
||||
|
||||
// Second call with same location and same config
|
||||
folders.isPathTrusted(locationPath);
|
||||
const secondCallCount = realpathSpy.mock.calls.length;
|
||||
|
||||
// Should still be 2 because both were cached
|
||||
expect(secondCallCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted with Symlinks', () => {
|
||||
const mockSettings: Settings = {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should trust a folder even if CWD is a symlink and rule is realpath', () => {
|
||||
const symlinkPath = '/var/folders/project';
|
||||
const realPath = '/private/var/folders/project';
|
||||
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(symlinkPath);
|
||||
|
||||
// Mock fs.existsSync to return true for trust config and both paths
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === getTrustedFoldersPath()) return true;
|
||||
if (pathStr === symlinkPath) return true;
|
||||
if (pathStr === realPath) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Mock realpathSync to resolve symlink to realpath
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === symlinkPath) return realPath;
|
||||
if (pathStr === realPath) return realPath;
|
||||
return pathStr;
|
||||
});
|
||||
|
||||
// Rule is saved with realpath
|
||||
const mockRules = {
|
||||
[realPath]: TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath())
|
||||
return JSON.stringify(mockRules);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Should be trusted because both resolve to the same realpath
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
|
||||
});
|
||||
|
||||
it('should trust a folder even if CWD is realpath and rule is a symlink', () => {
|
||||
const symlinkPath = '/var/folders/project';
|
||||
const realPath = '/private/var/folders/project';
|
||||
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(realPath);
|
||||
|
||||
// Mock fs.existsSync
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === getTrustedFoldersPath()) return true;
|
||||
if (pathStr === symlinkPath) return true;
|
||||
if (pathStr === realPath) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Mock realpathSync
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === symlinkPath) return realPath;
|
||||
if (pathStr === realPath) return realPath;
|
||||
return pathStr;
|
||||
});
|
||||
|
||||
// Rule is saved with symlink path
|
||||
const mockRules = {
|
||||
[symlinkPath]: TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath())
|
||||
return JSON.stringify(mockRules);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Should be trusted because both resolve to the same realpath
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,9 @@ export enum TrustLevel {
|
||||
DO_NOT_TRUST = 'DO_NOT_TRUST',
|
||||
}
|
||||
|
||||
export function isTrustLevel(value: unknown): value is TrustLevel {
|
||||
export function isTrustLevel(
|
||||
value: string | number | boolean | object | null | undefined,
|
||||
): value is TrustLevel {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
Object.values(TrustLevel).includes(value as TrustLevel)
|
||||
@@ -63,6 +65,32 @@ export interface TrustResult {
|
||||
source: 'ide' | 'file' | undefined;
|
||||
}
|
||||
|
||||
const realPathCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* FOR TESTING PURPOSES ONLY.
|
||||
* Clears the real path cache.
|
||||
*/
|
||||
export function clearRealPathCacheForTesting(): void {
|
||||
realPathCache.clear();
|
||||
}
|
||||
|
||||
function getRealPath(location: string): string {
|
||||
let realPath = realPathCache.get(location);
|
||||
if (realPath !== undefined) {
|
||||
return realPath;
|
||||
}
|
||||
|
||||
try {
|
||||
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
|
||||
} catch {
|
||||
realPath = location;
|
||||
}
|
||||
|
||||
realPathCache.set(location, realPath);
|
||||
return realPath;
|
||||
}
|
||||
|
||||
export class LoadedTrustedFolders {
|
||||
constructor(
|
||||
readonly user: TrustedFoldersFile,
|
||||
@@ -88,39 +116,36 @@ export class LoadedTrustedFolders {
|
||||
config?: Record<string, TrustLevel>,
|
||||
): boolean | undefined {
|
||||
const configToUse = config ?? this.user.config;
|
||||
const trustedPaths: string[] = [];
|
||||
const untrustedPaths: string[] = [];
|
||||
|
||||
for (const rule of Object.entries(configToUse).map(
|
||||
([path, trustLevel]) => ({ path, trustLevel }),
|
||||
)) {
|
||||
switch (rule.trustLevel) {
|
||||
case TrustLevel.TRUST_FOLDER:
|
||||
trustedPaths.push(rule.path);
|
||||
break;
|
||||
case TrustLevel.TRUST_PARENT:
|
||||
trustedPaths.push(path.dirname(rule.path));
|
||||
break;
|
||||
case TrustLevel.DO_NOT_TRUST:
|
||||
untrustedPaths.push(rule.path);
|
||||
break;
|
||||
default:
|
||||
// Do nothing for unknown trust levels.
|
||||
break;
|
||||
// Resolve location to its realpath for canonical comparison
|
||||
const realLocation = getRealPath(location);
|
||||
|
||||
let longestMatchLen = -1;
|
||||
let longestMatchTrust: TrustLevel | undefined = undefined;
|
||||
|
||||
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
|
||||
const effectivePath =
|
||||
trustLevel === TrustLevel.TRUST_PARENT
|
||||
? path.dirname(rulePath)
|
||||
: rulePath;
|
||||
|
||||
// Resolve effectivePath to its realpath for canonical comparison
|
||||
const realEffectivePath = getRealPath(effectivePath);
|
||||
|
||||
if (isWithinRoot(realLocation, realEffectivePath)) {
|
||||
if (rulePath.length > longestMatchLen) {
|
||||
longestMatchLen = rulePath.length;
|
||||
longestMatchTrust = trustLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const trustedPath of trustedPaths) {
|
||||
if (isWithinRoot(location, trustedPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const untrustedPath of untrustedPaths) {
|
||||
if (path.normalize(location) === path.normalize(untrustedPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
|
||||
if (
|
||||
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
|
||||
longestMatchTrust === TrustLevel.TRUST_PARENT
|
||||
)
|
||||
return true;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -150,6 +175,7 @@ let loadedTrustedFolders: LoadedTrustedFolders | undefined;
|
||||
*/
|
||||
export function resetTrustedFoldersForTesting(): void {
|
||||
loadedTrustedFolders = undefined;
|
||||
clearRealPathCacheForTesting();
|
||||
}
|
||||
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
@@ -161,11 +187,13 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
const userConfig: Record<string, TrustLevel> = {};
|
||||
|
||||
const userPath = getTrustedFoldersPath();
|
||||
// Load user trusted folders
|
||||
try {
|
||||
if (fs.existsSync(userPath)) {
|
||||
const content = fs.readFileSync(userPath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(stripJsonComments(content));
|
||||
const parsed = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
@@ -190,7 +218,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: userPath,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
178
packages/cli/src/test-utils/mockConfig.ts
Normal file
178
packages/cli/src/test-utils/mockConfig.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings, Settings } from '../config/settings.js';
|
||||
import { createTestMergedSettings } from '../config/settings.js';
|
||||
|
||||
/**
|
||||
* Creates a mocked Config object with default values and allows overrides.
|
||||
*/
|
||||
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
getSandbox: vi.fn(() => undefined),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
isInteractive: vi.fn(() => false),
|
||||
setTerminalBackground: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getPolicyEngine: vi.fn(() => ({})),
|
||||
getMessageBus: vi.fn(() => ({ subscribe: vi.fn() })),
|
||||
getHookSystem: vi.fn(() => ({
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
setSessionId: vi.fn(),
|
||||
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
|
||||
getExperimentalZedIntegration: vi.fn(() => false),
|
||||
isBrowserLaunchSuppressed: vi.fn(() => false),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
isYoloModeDisabled: vi.fn(() => false),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
isEventDrivenSchedulerEnabled: vi.fn(() => false),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getAllowedTools: vi.fn(() => []),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getDeferredCommand: vi.fn(() => undefined),
|
||||
getFileSystemService: vi.fn(() => ({})),
|
||||
clientVersion: '1.0.0',
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTools: vi.fn().mockReturnValue([]),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getAgentRegistry: vi.fn().mockReturnValue({}),
|
||||
getPromptRegistry: vi.fn().mockReturnValue({}),
|
||||
getResourceRegistry: vi.fn().mockReturnValue({}),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
isAdminEnabled: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
getFileService: vi.fn().mockReturnValue({}),
|
||||
getGitService: vi.fn().mockResolvedValue({}),
|
||||
getUserMemory: vi.fn().mockReturnValue(''),
|
||||
getGeminiMdFilePaths: vi.fn().mockReturnValue([]),
|
||||
getShowMemoryUsage: vi.fn().mockReturnValue(false),
|
||||
getAccessibility: vi.fn().mockReturnValue({}),
|
||||
getTelemetryEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''),
|
||||
getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'),
|
||||
getTelemetryTarget: vi.fn().mockReturnValue(''),
|
||||
getTelemetryOutfile: vi.fn().mockReturnValue(undefined),
|
||||
getTelemetryUseCollector: vi.fn().mockReturnValue(false),
|
||||
getTelemetryUseCliAuth: vi.fn().mockReturnValue(false),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
updateSystemInstructionIfInitialized: vi.fn().mockResolvedValue(undefined),
|
||||
getModelRouterService: vi.fn().mockReturnValue({}),
|
||||
getModelAvailabilityService: vi.fn().mockReturnValue({}),
|
||||
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(true),
|
||||
getFileFilteringEnableFuzzySearch: vi.fn().mockReturnValue(true),
|
||||
getFileFilteringRespectGeminiIgnore: vi.fn().mockReturnValue(true),
|
||||
getFileFilteringOptions: vi.fn().mockReturnValue({}),
|
||||
getCustomExcludes: vi.fn().mockReturnValue([]),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getBugCommand: vi.fn().mockReturnValue(undefined),
|
||||
getExtensionManagement: vi.fn().mockReturnValue(true),
|
||||
getExtensionLoader: vi.fn().mockReturnValue({}),
|
||||
getEnabledExtensions: vi.fn().mockReturnValue([]),
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
|
||||
getDisableLLMCorrection: vi.fn().mockReturnValue(false),
|
||||
getNoBrowser: vi.fn().mockReturnValue(false),
|
||||
getAgentsSettings: vi.fn().mockReturnValue({}),
|
||||
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
|
||||
getIdeMode: vi.fn().mockReturnValue(false),
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getUserCaching: vi.fn().mockResolvedValue(false),
|
||||
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
|
||||
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
|
||||
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
|
||||
reloadSkills: vi.fn().mockResolvedValue(undefined),
|
||||
reloadAgents: vi.fn().mockResolvedValue(undefined),
|
||||
getUseRipgrep: vi.fn().mockReturnValue(false),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||
getContinueOnFailedApiCall: vi.fn().mockReturnValue(false),
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(false),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
setShellExecutionConfig: vi.fn(),
|
||||
getEnablePromptCompletion: vi.fn().mockReturnValue(false),
|
||||
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
|
||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
|
||||
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
|
||||
getNextCompressionTruncationId: vi.fn().mockReturnValue(1),
|
||||
getUseWriteTodos: vi.fn().mockReturnValue(false),
|
||||
getFileExclusions: vi.fn().mockReturnValue({}),
|
||||
getEnableHooks: vi.fn().mockReturnValue(true),
|
||||
getEnableHooksUI: vi.fn().mockReturnValue(true),
|
||||
getMcpClientManager: vi.fn().mockReturnValue({
|
||||
getMcpInstructions: vi.fn().mockReturnValue(''),
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(100),
|
||||
getExcludeTools: vi.fn().mockReturnValue(new Set()),
|
||||
getAllowedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
/**
|
||||
* Creates a mocked LoadedSettings object for tests.
|
||||
*/
|
||||
export function createMockSettings(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): LoadedSettings {
|
||||
const merged = createTestMergedSettings(
|
||||
(overrides['merged'] as Partial<Settings>) || {},
|
||||
);
|
||||
|
||||
return {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
...overrides,
|
||||
merged,
|
||||
} as unknown as LoadedSettings;
|
||||
}
|
||||
@@ -224,7 +224,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const activeHooks = useHookDisplayState();
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
||||
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
||||
isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
() => isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
);
|
||||
|
||||
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
|
||||
|
||||
@@ -32,11 +32,12 @@ vi.mock('node:process', async () => {
|
||||
describe('FolderTrustDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
mockedCwd.mockReturnValue('/home/user/project');
|
||||
});
|
||||
|
||||
it('should render the dialog with title and description', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||
);
|
||||
|
||||
@@ -44,11 +45,12 @@ describe('FolderTrustDialog', () => {
|
||||
expect(lastFrame()).toContain(
|
||||
'Trusting a folder allows Gemini to execute commands it suggests.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { lastFrame, stdin } = renderWithProviders(
|
||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
|
||||
);
|
||||
|
||||
@@ -67,24 +69,27 @@ describe('FolderTrustDialog', () => {
|
||||
);
|
||||
});
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display restart message when isRestarting is true', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Gemini CLI is restarting');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call relaunchApp when isRestarting is true', async () => {
|
||||
vi.useFakeTimers();
|
||||
const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
|
||||
renderWithProviders(
|
||||
const { unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(relaunchApp).toHaveBeenCalled();
|
||||
unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -106,7 +111,7 @@ describe('FolderTrustDialog', () => {
|
||||
});
|
||||
|
||||
it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
|
||||
);
|
||||
|
||||
@@ -117,31 +122,35 @@ describe('FolderTrustDialog', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockedExit).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('directory display', () => {
|
||||
it('should correctly display the folder name for a nested directory', () => {
|
||||
mockedCwd.mockReturnValue('/home/user/project');
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('Trust folder (project)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should correctly display the parent folder name for a nested directory', () => {
|
||||
mockedCwd.mockReturnValue('/home/user/project');
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('Trust parent folder (user)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should correctly display an empty parent folder name for a directory directly under root', () => {
|
||||
mockedCwd.mockReturnValue('/project');
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('Trust parent folder ()');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,17 +21,6 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('toolMapping', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -74,7 +74,6 @@ export const useThemeCommand = (
|
||||
const handleThemeSelect = useCallback(
|
||||
(themeName: string, scope: LoadableSettingScope) => {
|
||||
try {
|
||||
// Merge user and workspace custom themes (workspace takes precedence)
|
||||
const mergedCustomThemes = {
|
||||
...(loadedSettings.user.settings.ui?.customThemes || {}),
|
||||
...(loadedSettings.workspace.settings.ui?.customThemes || {}),
|
||||
|
||||
@@ -30,6 +30,18 @@ export const getAsciiArtWidth = (asciiArt: string): number => {
|
||||
* code units so that surrogate‑pair emoji count as one "column".)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Checks if a string contains only ASCII characters (0-127).
|
||||
*/
|
||||
export function isAscii(str: string): boolean {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str.charCodeAt(i) > 127) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cache for code points
|
||||
const MAX_STRING_LENGTH_TO_CACHE = 1000;
|
||||
const codePointsCache = new LRUCache<string, string[]>(
|
||||
@@ -37,15 +49,8 @@ const codePointsCache = new LRUCache<string, string[]>(
|
||||
);
|
||||
|
||||
export function toCodePoints(str: string): string[] {
|
||||
// ASCII fast path - check if all chars are ASCII (0-127)
|
||||
let isAscii = true;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str.charCodeAt(i) > 127) {
|
||||
isAscii = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isAscii) {
|
||||
// ASCII fast path
|
||||
if (isAscii(str)) {
|
||||
return str.split('');
|
||||
}
|
||||
|
||||
@@ -68,6 +73,9 @@ export function toCodePoints(str: string): string[] {
|
||||
}
|
||||
|
||||
export function cpLen(str: string): number {
|
||||
if (isAscii(str)) {
|
||||
return str.length;
|
||||
}
|
||||
return toCodePoints(str).length;
|
||||
}
|
||||
|
||||
@@ -79,6 +87,9 @@ export function cpIndexToOffset(str: string, cpIndex: number): number {
|
||||
}
|
||||
|
||||
export function cpSlice(str: string, start: number, end?: number): string {
|
||||
if (isAscii(str)) {
|
||||
return str.slice(start, end);
|
||||
}
|
||||
// Slice by code‑point indices and re‑join.
|
||||
const arr = toCodePoints(str).slice(start, end);
|
||||
return arr.join('');
|
||||
|
||||
Reference in New Issue
Block a user