Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 56 additions & 6 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,20 @@ import {
SETTINGS_VERSION_KEY,
} from './settings.js';
import { needsMigration } from './migration/index.js';
import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core';
import {
FatalConfigError,
QWEN_DIR,
normalizePathForComparison,
} from '@qwen-code/qwen-code-core';

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
normalizePathForComparison: vi.fn((p: string) => p),
};
});

const MOCK_WORKSPACE_DIR = '/mock/workspace';
// Use the (mocked) SETTINGS_DIRECTORY_NAME for consistency
Expand Down Expand Up @@ -84,7 +97,7 @@ vi.mock('node:fs', async (importOriginal) => {
writeFileSync: vi.fn(),
renameSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: (p: string) => p,
realpathSync: vi.fn(),
};
});

Expand All @@ -101,7 +114,7 @@ vi.mock('fs', async (importOriginal) => {
writeFileSync: vi.fn(),
renameSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: (p: string) => p,
realpathSync: vi.fn(),
};
});

Expand All @@ -117,13 +130,15 @@ describe('Settings Loading and Merging', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
let mockFsMkdirSync: Mocked<typeof fs.mkdirSync>;
let mockFsRealpathSync: Mocked<typeof fs.realpathSync>;

beforeEach(() => {
vi.resetAllMocks();

mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsMkdirSync = vi.mocked(fs.mkdirSync);
mockStripJsonComments = vi.mocked(stripJsonComments);
mockFsRealpathSync = vi.mocked(fs.realpathSync);

vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
(mockStripJsonComments as unknown as Mock).mockImplementation(
Expand All @@ -132,6 +147,8 @@ describe('Settings Loading and Merging', () => {
(mockFsExistsSync as Mock).mockReturnValue(false);
(fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON
(mockFsMkdirSync as Mock).mockImplementation(() => undefined);
(mockFsRealpathSync as Mock).mockImplementation((p: string) => p);
vi.mocked(normalizePathForComparison).mockImplementation((p: string) => p);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
Expand Down Expand Up @@ -2328,9 +2345,42 @@ describe('Settings Loading and Merging', () => {

const settings = loadSettings(MOCK_WORKSPACE_DIR);

expect(settings.merged.tools?.sandbox).toBe(false); // User setting
expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting
expect(settings.merged.ui?.theme).toBe('dark'); // User setting
expect(settings.merged.tools?.sandbox).toBe(false);
expect(settings.merged.context?.fileName).toBe('USER.md');
expect(settings.merged.ui?.theme).toBe('dark');
});

it('should handle case-insensitive workspace vs home on Windows - different paths', () => {
vi.mocked(osActual.platform).mockReturnValue('win32');
vi.mocked(osActual.homedir).mockReturnValue('C:\\Users\\testuser');
(mockFsRealpathSync as Mock).mockImplementation((p: string) => p);
vi.mocked(normalizePathForComparison).mockImplementation((p: string) =>
pathActual.normalize(p).toLowerCase(),
);

loadSettings('C:\\Projects\\myproject');

expect(fs.existsSync).toHaveBeenCalledWith(
'C:\\Projects\\myproject\\.qwen\\settings.json',
);
});

it('should remain case-sensitive for workspace vs home on POSIX', () => {
vi.mocked(osActual.platform).mockReturnValue('linux');
vi.mocked(osActual.homedir).mockReturnValue('/home/user');
(mockFsRealpathSync as Mock).mockImplementation((p: string) => p);
vi.mocked(normalizePathForComparison).mockImplementation((p: string) =>
pathActual.normalize(p),
);

loadSettings('/HOME/USER/project');

const expectedPath = pathActual.join(
'/HOME/USER/project',
'.qwen',
'settings.json',
);
expect(fs.existsSync).toHaveBeenCalledWith(expectedPath);
});
});

Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getErrorMessage,
Storage,
createDebugLogger,
normalizePathForComparison,
} from '@qwen-code/qwen-code-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
Expand Down Expand Up @@ -559,13 +560,23 @@ export function loadSettings(
// This is okay. The path might not exist yet, and that's a valid state.
}

// We expect homedir to always exist and be resolvable.
const realHomeDir = fs.realpathSync(resolvedHomeDir);
let realHomeDir = resolvedHomeDir;
try {
// We expect homedir to always exist and be resolvable, but guard against edge cases
realHomeDir = fs.realpathSync(resolvedHomeDir);
} catch (_e) {
// Fallback to resolved path if realpath fails
realHomeDir = resolvedHomeDir;
}

const workspaceSettingsPath = new Storage(
workspaceDir,
).getWorkspaceSettingsPath();

const isWorkspaceSameAsHome =
normalizePathForComparison(realWorkspaceDir) ===
normalizePathForComparison(realHomeDir);

const loadAndMigrate = (
filePath: string,
scope: SettingScope,
Expand Down Expand Up @@ -666,7 +677,7 @@ export function loadSettings(
settings: {} as Settings,
rawJson: undefined,
};
if (realWorkspaceDir !== realHomeDir) {
if (!isWorkspaceSameAsHome) {
workspaceResult = loadAndMigrate(
workspaceSettingsPath,
SettingScope.Workspace,
Expand Down
62 changes: 61 additions & 1 deletion packages/cli/src/config/trustedFolders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/

import * as osActual from 'node:os';
import { FatalConfigError, ideContextStore } from '@qwen-code/qwen-code-core';
import {
FatalConfigError,
ideContextStore,
normalizePathForComparison,
} from '@qwen-code/qwen-code-core';
import {
describe,
it,
Expand All @@ -15,6 +19,7 @@ import {
afterEach,
type Mocked,
type Mock,
type MockedFunction,
} from 'vitest';
import * as fs from 'node:fs';
import stripJsonComments from 'strip-json-comments';
Expand All @@ -36,6 +41,16 @@ vi.mock('os', async (importOriginal) => {
platform: vi.fn(() => 'linux'),
};
});

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
normalizePathForComparison: vi.fn((p: string) => p),
};
});

vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof fs>();
return {
Expand All @@ -44,6 +59,7 @@ vi.mock('fs', async (importOriginal) => {
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: vi.fn(),
};
});
vi.mock('strip-json-comments', () => ({
Expand All @@ -54,19 +70,23 @@ describe('Trusted Folders Loading', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>;
let mockFsRealpathSync: MockedFunction<typeof fs.realpathSync>;

beforeEach(() => {
resetTrustedFoldersForTesting();
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockStripJsonComments = vi.mocked(stripJsonComments);
mockFsWriteFileSync = vi.mocked(fs.writeFileSync);
mockFsRealpathSync = vi.mocked(fs.realpathSync);
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('{}');
mockFsRealpathSync.mockImplementation((p) => p as string);
vi.mocked(normalizePathForComparison).mockImplementation((p: string) => p);
});

afterEach(() => {
Expand Down Expand Up @@ -123,6 +143,46 @@ describe('Trusted Folders Loading', () => {
);
expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined);
});

it('should handle case-insensitive path matching on Windows', () => {
vi.mocked(osActual.platform).mockReturnValue('win32');
vi.mocked(normalizePathForComparison).mockImplementation((p: string) =>
path.normalize(p).toLowerCase(),
);

const { folders } = setup({
config: {
'C:\\Users\\folder': TrustLevel.TRUST_FOLDER,
'C:\\Secret': TrustLevel.DO_NOT_TRUST,
},
});

expect(folders.isPathTrusted('C:\\USERS\\FOLDER\\file.txt')).toBe(true);
expect(folders.isPathTrusted('c:\\users\\folder')).toBe(true);
expect(folders.isPathTrusted('C:\\SECRET\\file.txt')).toBe(undefined);
expect(folders.isPathTrusted('c:\\secret')).toBe(false);
});

it('should remain case-sensitive on POSIX systems', () => {
vi.mocked(osActual.platform).mockReturnValue('linux');
vi.mocked(normalizePathForComparison).mockImplementation((p: string) =>
path.normalize(p),
);

const { folders } = setup({
config: {
'/home/user/folder': TrustLevel.TRUST_FOLDER,
'/secret': TrustLevel.DO_NOT_TRUST,
},
});

expect(folders.isPathTrusted('/home/user/folder/file.txt')).toBe(true);
expect(folders.isPathTrusted('/secret/file.txt')).toBe(undefined);
expect(folders.isPathTrusted('/home/user/other/file.txt')).toBe(
undefined,
);
expect(folders.isPathTrusted('/other/file.txt')).toBe(undefined);
});
});

it('should load user rules if only user file exists', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/config/trustedFolders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getErrorMessage,
isWithinRoot,
ideContextStore,
normalizePathForComparison,
} from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
Expand Down Expand Up @@ -101,8 +102,11 @@ export class LoadedTrustedFolders {
}
}

const normalizedLocation = normalizePathForComparison(location);

for (const untrustedPath of untrustedPaths) {
if (path.normalize(location) === path.normalize(untrustedPath)) {
const normalizedUntrustedPath = normalizePathForComparison(untrustedPath);
if (normalizedLocation === normalizedUntrustedPath) {
return false;
}
}
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/utils/fileUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type osActual from 'node:os';
import mime from 'mime/lite';

import {
Expand All @@ -35,6 +36,15 @@ import {
import type { Config } from '../config/config.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';

vi.mock('node:os', async (importOriginal) => {
const actualOs = await importOriginal<typeof osActual>();
return {
...actualOs,
homedir: vi.fn(() => '/mock/home/user'),
platform: vi.fn(() => 'linux'),
};
});

vi.mock('mime/lite', () => ({
default: { getType: vi.fn() },
getType: vi.fn(),
Expand Down Expand Up @@ -148,6 +158,35 @@ describe('fileUtils', () => {
const rootSuper = path.resolve('/project/root/sub');
expect(isWithinRoot(pathToCheckSuper, rootSuper)).toBe(false);
});

it('should handle case-insensitive comparison on Windows', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');

const root = 'C:\\Project\\Root';
expect(isWithinRoot('C:\\PROJECT\\ROOT\\file.txt', root)).toBe(true);
expect(isWithinRoot('c:\\project\\root\\subdir\\file.txt', root)).toBe(
true,
);
expect(isWithinRoot('C:\\PROJECT\\ROOT', root)).toBe(true);
expect(isWithinRoot('C:\\Project\\Other', root)).toBe(false);
});

it('should handle case-insensitive root comparison on Windows', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');

const root = 'c:\\project\\root';
expect(isWithinRoot('C:\\PROJECT\\ROOT\\file.txt', root)).toBe(true);
expect(isWithinRoot('C:\\Project\\Root', root)).toBe(true);
});

it('should remain case-sensitive on POSIX systems', () => {
vi.spyOn(os, 'platform').mockReturnValue('linux');

const root = '/project/root';
expect(isWithinRoot('/project/root/file.txt', root)).toBe(true);
expect(isWithinRoot('/PROJECT/ROOT/file.txt', root)).toBe(false);
expect(isWithinRoot('/project/ROOT', root)).toBe(false);
});
});

describe('fileExists', () => {
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/utils/fileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { Config } from '../config/config.js';
import { createDebugLogger } from './debugLogger.js';
import type { InputModalities } from '../core/contentGenerator.js';
import { detectEncodingFromBuffer } from './systemEncoding.js';
import { normalizePathForComparison } from './paths.js';

const debugLogger = createDebugLogger('FILE_UTILS');

Expand Down Expand Up @@ -380,17 +381,19 @@ export function isWithinRoot(
const normalizedPathToCheck = path.resolve(pathToCheck);
const normalizedRootDirectory = path.resolve(rootDirectory);

// Ensure the rootDirectory path ends with a separator for correct startsWith comparison,
// unless it's the root path itself (e.g., '/' or 'C:\').
const rootWithSeparator =
normalizedRootDirectory === path.sep ||
normalizedRootDirectory.endsWith(path.sep)
? normalizedRootDirectory
: normalizedRootDirectory + path.sep;

const pathToCompare = normalizePathForComparison(normalizedPathToCheck);
const rootWithSepToCompare = normalizePathForComparison(rootWithSeparator);
const rootDirToCompare = normalizePathForComparison(normalizedRootDirectory);

return (
normalizedPathToCheck === normalizedRootDirectory ||
normalizedPathToCheck.startsWith(rootWithSeparator)
pathToCompare === rootDirToCompare ||
pathToCompare.startsWith(rootWithSepToCompare)
);
}

Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/utils/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,15 @@ export function isSubpaths(parentPath: string[], childPath: string): boolean {
return parentPath.some((p) => isSubpath(p, childPath));
}

export function normalizePathForComparison(p: string): string {
const normalized = path.normalize(p);
// Windows and macOS (default) have case-insensitive file systems
// Linux is case-sensitive
return os.platform() === 'win32' || os.platform() === 'darwin'
? normalized.toLowerCase()
: normalized;
}

/**
* Resolves a path with tilde (~) expansion and relative path resolution.
* Handles tilde expansion for home directory and resolves relative paths
Expand Down