diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 8ef0283c509..f9d0107e55a 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -6,8 +6,8 @@ import * as path from 'node:path'; import * as os from 'node:os'; -import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; +import { getProjectHash } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; @@ -88,9 +88,10 @@ export class Storage { } getProjectTempDir(): string { - const hash = this.getFilePathHash(this.getProjectRoot()); + const hash = getProjectHash(this.getProjectRoot()); const tempDir = Storage.getGlobalTempDir(); - return path.join(tempDir, hash); + const targetDir = path.join(tempDir, hash); + return targetDir; } ensureProjectTempDirExists(): void { @@ -105,14 +106,11 @@ export class Storage { return this.targetDir; } - private getFilePathHash(filePath: string): string { - return crypto.createHash('sha256').update(filePath).digest('hex'); - } - getHistoryDir(): string { - const hash = this.getFilePathHash(this.getProjectRoot()); + const hash = getProjectHash(this.getProjectRoot()); const historyDir = path.join(Storage.getGlobalQwenDir(), 'history'); - return path.join(historyDir, hash); + const targetDir = path.join(historyDir, hash); + return targetDir; } getWorkspaceSettingsPath(): string { @@ -144,6 +142,8 @@ export class Storage { } private sanitizeCwd(cwd: string): string { - return cwd.replace(/[^a-zA-Z0-9]/g, '-'); + // On Windows, normalize to lowercase for case-insensitive matching + const normalizedCwd = os.platform() === 'win32' ? cwd.toLowerCase() : cwd; + return normalizedCwd.replace(/[^a-zA-Z0-9]/g, '-'); } } diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index de3fc3f787c..c973c02dd3b 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -21,11 +21,11 @@ import { decodeTagName, } from './logger.js'; import { Storage } from '../config/storage.js'; +import { getProjectHash } from '../utils/paths.js'; import { promises as fs, existsSync } from 'node:fs'; import path from 'node:path'; import type { Content } from '@google/genai'; -import crypto from 'node:crypto'; import os from 'node:os'; const GEMINI_DIR_NAME = '.qwen'; @@ -34,7 +34,7 @@ const LOG_FILE_NAME = 'logs.json'; const CHECKPOINT_FILE_NAME = 'checkpoint.json'; const projectDir = process.cwd(); -const hash = crypto.createHash('sha256').update(projectDir).digest('hex'); +const hash = getProjectHash(projectDir); const TEST_HOME_DIR = path.join(os.tmpdir(), 'qwen-core-logger-home'); let originalHome: string | undefined; diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 1c4ee0225f5..9f8b63ef97b 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -17,6 +17,7 @@ import { isSubpath, shortenPath, tildeifyPath, + getProjectHash, } from './paths.js'; import type { Config } from '../config/config.js'; @@ -770,3 +771,80 @@ describe('shortenPath', () => { expect(result.length).toBeLessThanOrEqual(35); }); }); + +describe('getProjectHash', () => { + it('should generate consistent hashes for the same path', () => { + const projectRoot = '/test/project'; + const hash1 = getProjectHash(projectRoot); + const hash2 = getProjectHash(projectRoot); + + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); // SHA256 produces 64 hex characters + }); + + it('should generate different hashes for different paths', () => { + const hash1 = getProjectHash('/test/project1'); + const hash2 = getProjectHash('/test/project2'); + + expect(hash1).not.toBe(hash2); + }); + + it('should generate case-insensitive hashes on Windows', () => { + const platformSpy = vi.spyOn(os, 'platform'); + + // Simulate Windows platform + platformSpy.mockReturnValue('win32'); + + const lowerCasePath = 'c:\\users\\test\\project'; + const upperCasePath = 'C:\\Users\\Test\\Project'; + const mixedCasePath = 'c:\\Users\\TEST\\project'; + + const hash1 = getProjectHash(lowerCasePath); + const hash2 = getProjectHash(upperCasePath); + const hash3 = getProjectHash(mixedCasePath); + + // On Windows, all different case variations should produce the same hash + expect(hash1).toBe(hash2); + expect(hash2).toBe(hash3); + + platformSpy.mockRestore(); + }); + + it('should generate case-sensitive hashes on non-Windows platforms', () => { + const platformSpy = vi.spyOn(os, 'platform'); + + // Simulate Unix/Linux platform + platformSpy.mockReturnValue('linux'); + + const lowerCasePath = '/home/user/project'; + const upperCasePath = '/HOME/USER/PROJECT'; + + const hash1 = getProjectHash(lowerCasePath); + const hash2 = getProjectHash(upperCasePath); + + // On non-Windows platforms, different case should produce different hashes + expect(hash1).not.toBe(hash2); + + platformSpy.mockRestore(); + }); + + it('should handle Windows drive letter variations', () => { + const platformSpy = vi.spyOn(os, 'platform'); + platformSpy.mockReturnValue('win32'); + + // Common Windows scenarios where users might have different drive letter cases + const scenarios = [ + ['e:\\work', 'E:\\work'], + ['e:\\work', 'E:\\WORK'], + ['c:\\projects\\myapp', 'C:\\Projects\\MyApp'], + ]; + + for (const [path1, path2] of scenarios) { + const hash1 = getProjectHash(path1); + const hash2 = getProjectHash(path2); + expect(hash1).toBe(hash2); + } + + platformSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 6b492c92291..96856a5dcc6 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -190,11 +190,16 @@ export function unescapePath(filePath: string): string { /** * Generates a unique hash for a project based on its root path. + * On Windows, paths are case-insensitive, so we normalize to lowercase + * to ensure the same physical path always produces the same hash. * @param projectRoot The absolute path to the project's root directory. * @returns A SHA256 hash of the project root path. */ export function getProjectHash(projectRoot: string): string { - return crypto.createHash('sha256').update(projectRoot).digest('hex'); + // On Windows, normalize path to lowercase for case-insensitive matching + const normalizedPath = + os.platform() === 'win32' ? projectRoot.toLowerCase() : projectRoot; + return crypto.createHash('sha256').update(normalizedPath).digest('hex'); } /** diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 9336a060bfd..a5e817cad42 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; +import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; /** @@ -28,19 +29,12 @@ export class QwenSessionManager { } /** - * Calculate project hash (same as CLI) - * Qwen CLI uses SHA256 hash of the project path - */ - private getProjectHash(workingDir: string): string { - return crypto.createHash('sha256').update(workingDir).digest('hex'); - } - - /** - * Get the session directory for a project + * Get the session directory for a project with backward compatibility */ private getSessionDir(workingDir: string): string { - const projectHash = this.getProjectHash(workingDir); - return path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + const projectHash = getProjectHash(workingDir); + const sessionDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + return sessionDir; } /** @@ -87,7 +81,7 @@ export class QwenSessionManager { // Create session object const session: QwenSession = { sessionId, - projectHash: this.getProjectHash(workingDir), + projectHash: getProjectHash(workingDir), startTime: messages[0]?.timestamp || new Date().toISOString(), lastUpdated: new Date().toISOString(), messages, diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 3fc4e484f23..0a65b0cb69d 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as readline from 'readline'; -import * as crypto from 'crypto'; +import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; export interface QwenMessage { id: string; @@ -58,7 +58,7 @@ export class QwenSessionReader { if (!allProjects && workingDir) { // Current project only - const projectHash = await this.getProjectHash(workingDir); + const projectHash = getProjectHash(workingDir); const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); const projectSessions = await this.readSessionsFromDir(chatsDir); sessions.push(...projectSessions); @@ -177,14 +177,6 @@ export class QwenSessionReader { return found; } - /** - * Calculate project hash (needs to be consistent with Qwen CLI) - * Qwen CLI uses SHA256 hash of project path - */ - private async getProjectHash(workingDir: string): Promise { - return crypto.createHash('sha256').update(workingDir).digest('hex'); - } - /** * Get session title (based on first user message) */ @@ -289,7 +281,7 @@ export class QwenSessionReader { } const projectHash = cwd - ? await this.getProjectHash(cwd) + ? getProjectHash(cwd) : path.basename(path.dirname(path.dirname(filePath))); return { diff --git a/scripts/telemetry_utils.js b/scripts/telemetry_utils.js index cb2010d5b69..504ed18cb8c 100644 --- a/scripts/telemetry_utils.js +++ b/scripts/telemetry_utils.js @@ -18,10 +18,21 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); -const projectHash = crypto - .createHash('sha256') - .update(projectRoot) - .digest('hex'); + +/** + * Generates a unique hash for a project based on its root path. + * On Windows, paths are case-insensitive, so we normalize to lowercase + * to ensure the same physical path always produces the same hash. + * This logic must match getProjectHash() in packages/core/src/utils/paths.ts + */ +function getProjectHash(projectRoot) { + // On Windows, normalize path to lowercase for case-insensitive matching + const normalizedPath = + os.platform() === 'win32' ? projectRoot.toLowerCase() : projectRoot; + return crypto.createHash('sha256').update(normalizedPath).digest('hex'); +} + +const projectHash = getProjectHash(projectRoot); // User-level .gemini directory in home const USER_GEMINI_DIR = path.join(os.homedir(), '.qwen');