From b649fb50bddaff2b9ffe18fe145cbbd3dc420093 Mon Sep 17 00:00:00 2001 From: YC Lian Date: Mon, 9 Feb 2026 12:44:58 +0800 Subject: [PATCH 1/4] fix(core): improve windows path casing and validation * Preserve original path casing in Config and ProjectRegistry by removing forced lowercasing * Enhance WorkspaceContext to support case-insensitive path validation on Windows * Add unit tests for mixed-case path scenarios --- packages/core/src/config/config.test.ts | 20 +++++++++ packages/core/src/config/config.ts | 3 +- .../core/src/config/projectRegistry.test.ts | 24 +++++++++++ packages/core/src/config/projectRegistry.ts | 7 +--- .../core/src/utils/workspaceContext.test.ts | 42 +++++++++++++++++++ packages/core/src/utils/workspaceContext.ts | 41 +++++++++++++++--- 6 files changed, 123 insertions(+), 14 deletions(-) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index d2c460d2408..2e2522bc744 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -2397,3 +2397,23 @@ describe('syncPlanModeTools', () => { expect(setToolsSpy).toHaveBeenCalled(); }); }); + +describe('isPathAllowed', () => { + it('should preserve casing when validating paths', () => { + const params: ConfigParameters = { + cwd: '/tmp', + targetDir: '/tmp/target', + model: DEFAULT_GEMINI_MODEL, + sessionId: 'test-session', + debugMode: false, + }; + const config = new Config(params); + const workspaceContext = config.getWorkspaceContext(); + const spy = vi.spyOn(workspaceContext, 'isPathWithinWorkspace'); + + const mixedCasePath = path.join('/tmp/target', 'SubDir', 'File.txt'); + config.isPathAllowed(mixedCasePath); + expect(spy).toHaveBeenCalledWith(expect.stringMatching(/File\.txt$/)); + expect(spy).toHaveBeenCalledWith(mixedCasePath); + }); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 92e20f91638..d47bd8862dc 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { inspect } from 'node:util'; import process from 'node:process'; import type { @@ -1860,7 +1859,7 @@ export class Config { } catch { resolved = path.resolve(p); } - return os.platform() === 'win32' ? resolved.toLowerCase() : resolved; + return resolved; }; const resolvedPath = realpath(absolutePath); diff --git a/packages/core/src/config/projectRegistry.test.ts b/packages/core/src/config/projectRegistry.test.ts index a441de8b3e2..087737fe42e 100644 --- a/packages/core/src/config/projectRegistry.test.ts +++ b/packages/core/src/config/projectRegistry.test.ts @@ -107,6 +107,30 @@ describe('ProjectRegistry', () => { expect(id1).toBe(id2); }); + it('preserves path casing on Windows', async () => { + // Mock platform to match Windows behavior for this test + // Note: The actual code uses os.platform(), but for this unit test + // we rely on the implementation of consume/normalize not mutating case. + // Since we removed toLowerCase() from the implementation, we expect + // case to be preserved regardless of platform mock, but conceptually + // this safeguards the regression. + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + + const mixedCasePath = path.join(tempDir, 'MyProject'); + const id = await registry.getShortId(mixedCasePath); + // The getShortId method uses slugify() which converts to lowercase. + // So the ID should be 'myproject'. + // However, we want to ensure that the REGISTRY MAPPING preserves the case of the KEY (project path). + expect(id).toBe('myproject'); + + // Verify the mapping in the registry data uses the original case + const data = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + // helper normalizePath in this test file just resolves (no lowercase) + const normalizedKey = normalizePath(mixedCasePath); + expect(data.projects[normalizedKey]).toBe('myproject'); + }); + it('creates ownership markers in base directories', async () => { const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]); await registry.initialize(); diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts index 225faedf9bf..aff6bb15822 100644 --- a/packages/core/src/config/projectRegistry.ts +++ b/packages/core/src/config/projectRegistry.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { lock } from 'proper-lockfile'; import { debugLogger } from '../utils/debugLogger.js'; @@ -68,11 +67,7 @@ export class ProjectRegistry { } private normalizePath(projectPath: string): string { - let resolved = path.resolve(projectPath); - if (os.platform() === 'win32') { - resolved = resolved.toLowerCase(); - } - return resolved; + return path.resolve(projectPath); } private async save(data: RegistryData): Promise { diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts index 8c29819a79e..c8c7a36cb43 100644 --- a/packages/core/src/utils/workspaceContext.test.ts +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -139,6 +139,48 @@ describe('WorkspaceContext with real filesystem', () => { expect(workspaceContext.isPathWithinWorkspace(invalidPath)).toBe(false); }); + it('should match paths case-insensitively on Windows', () => { + // Force win32 platform for this test + const originalPlatform = os.platform(); + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + // Setup: cwd is lowercase-ish, checkPath is MixedCase + // In the real fix, we normalize BOTH to lowercase for comparison. + // Let's assume cwd is '/tmp/project' + const lowerCwd = cwd.toLowerCase(); + // Ensure the directory exists for the test to pass validation + if (!fs.existsSync(lowerCwd)) { + fs.mkdirSync(lowerCwd, { recursive: true }); + } + const workspaceContext = new WorkspaceContext(lowerCwd); + + // A path that matches case-insensitively + const mixedCasePath = path.join(lowerCwd, 'SubDir', 'File.txt'); + expect(workspaceContext.isPathWithinWorkspace(mixedCasePath)).toBe( + true, + ); + + // Case 1: Root is lower, Check is Upper + const upperPath = path.join(lowerCwd.toUpperCase(), 'FILE.TXT'); + expect(workspaceContext.isPathWithinWorkspace(upperPath)).toBe(true); + + // Case 2: Root is Upper, Check is Lower + // Use a mixed case directory to simulate case-insensitivity without hitting root-level EACCES + const mixedRoot = path.join(cwd, 'MixedCaseRoot'); + if (!fs.existsSync(mixedRoot)) { + fs.mkdirSync(mixedRoot); + } + const workspaceContextMixed = new WorkspaceContext(mixedRoot); + const lowerPathValues = path.join(mixedRoot.toLowerCase(), 'file.txt'); + expect( + workspaceContextMixed.isPathWithinWorkspace(lowerPathValues), + ).toBe(true); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('should handle nested directories correctly', () => { const workspaceContext = new WorkspaceContext(cwd, [otherDir]); const nestedPath = path.join(cwd, 'deeply', 'nested', 'path', 'file.txt'); diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index ff912083fb4..cc206a10c69 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -6,6 +6,7 @@ import { isNodeError } from '../utils/errors.js'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { debugLogger } from './debugLogger.js'; @@ -22,6 +23,7 @@ export interface AddDirectoriesResult { * in a single session. */ export class WorkspaceContext { + readonly targetDir: string; private directories = new Set(); private initialDirectories: Set; private onDirectoriesChangedListeners = new Set<() => void>(); @@ -31,10 +33,13 @@ export class WorkspaceContext { * @param targetDir The initial working directory (usually cwd) * @param additionalDirectories Optional array of additional directories to include */ - constructor( - readonly targetDir: string, - additionalDirectories: string[] = [], - ) { + constructor(targetDir: string, additionalDirectories: string[] = []) { + // Ensure targetDir is in original case from filesystem + try { + this.targetDir = fs.realpathSync(targetDir); + } catch { + this.targetDir = targetDir; + } this.addDirectory(targetDir); this.addDirectories(additionalDirectories); this.initialDirectories = new Set(this.directories); @@ -181,7 +186,26 @@ export class WorkspaceContext { */ private fullyResolvedPath(pathToCheck: string): string { try { - return fs.realpathSync(path.resolve(this.targetDir, pathToCheck)); + let resolvedInput = path.resolve(this.targetDir, pathToCheck); + + // On Windows, if pathToCheck is already absolute with incorrect casing, fix it + if (os.platform() === 'win32' && path.isAbsolute(pathToCheck)) { + try { + resolvedInput = fs.realpathSync(pathToCheck); + } catch { + // Normalize the case by matching against targetDir + const normalizedPathToCheck = pathToCheck.toLowerCase(); + const normalizedTargetDir = this.targetDir.toLowerCase(); + + if (normalizedPathToCheck.startsWith(normalizedTargetDir)) { + resolvedInput = + this.targetDir + + pathToCheck.substring(normalizedTargetDir.length); + } + } + } + + return fs.realpathSync(resolvedInput); } catch (e: unknown) { if ( isNodeError(e) && @@ -208,7 +232,12 @@ export class WorkspaceContext { pathToCheck: string, rootDirectory: string, ): boolean { - const relative = path.relative(rootDirectory, pathToCheck); + const normalizedRoot = + os.platform() === 'win32' ? rootDirectory.toLowerCase() : rootDirectory; + const normalizedPath = + os.platform() === 'win32' ? pathToCheck.toLowerCase() : pathToCheck; + + const relative = path.relative(normalizedRoot, normalizedPath); return ( !relative.startsWith(`..${path.sep}`) && relative !== '..' && From 140fc98aa061eb4f2b880c36f4d8042be7c8f18a Mon Sep 17 00:00:00 2001 From: YC Lian Date: Mon, 9 Feb 2026 15:46:53 +0800 Subject: [PATCH 2/4] test(core): fix path case preservation verification on Windows --- .../core/src/config/projectRegistry.test.ts | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/core/src/config/projectRegistry.test.ts b/packages/core/src/config/projectRegistry.test.ts index 087737fe42e..846207a862b 100644 --- a/packages/core/src/config/projectRegistry.test.ts +++ b/packages/core/src/config/projectRegistry.test.ts @@ -108,27 +108,22 @@ describe('ProjectRegistry', () => { }); it('preserves path casing on Windows', async () => { - // Mock platform to match Windows behavior for this test - // Note: The actual code uses os.platform(), but for this unit test - // we rely on the implementation of consume/normalize not mutating case. - // Since we removed toLowerCase() from the implementation, we expect - // case to be preserved regardless of platform mock, but conceptually - // this safeguards the regression. + // Regression test: Mixed-case paths MUST be preserved in the registry (not lowercased). const registry = new ProjectRegistry(registryPath); await registry.initialize(); const mixedCasePath = path.join(tempDir, 'MyProject'); const id = await registry.getShortId(mixedCasePath); - // The getShortId method uses slugify() which converts to lowercase. - // So the ID should be 'myproject'. - // However, we want to ensure that the REGISTRY MAPPING preserves the case of the KEY (project path). + + // IDs are slugified (lowercase), but path keys in registry must remain case-sensitive. expect(id).toBe('myproject'); - // Verify the mapping in the registry data uses the original case + // Verify raw registry file data preserves path casing const data = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - // helper normalizePath in this test file just resolves (no lowercase) - const normalizedKey = normalizePath(mixedCasePath); - expect(data.projects[normalizedKey]).toBe('myproject'); + const expectedKey = path.resolve(mixedCasePath); + + expect(Object.keys(data.projects)).toContain(expectedKey); + expect(data.projects[expectedKey]).toBe('myproject'); }); it('creates ownership markers in base directories', async () => { From 29e83fa2efd3f9235b9f90c231b7c67d61648b0c Mon Sep 17 00:00:00 2001 From: YC Lian Date: Mon, 9 Feb 2026 12:44:58 +0800 Subject: [PATCH 3/4] fix(core): improve windows path casing and validation * Preserve original path casing in Config and ProjectRegistry by removing forced lowercasing * Enhance WorkspaceContext to support case-insensitive path validation on Windows * Add unit tests for mixed-case path scenarios --- packages/core/src/config/config.test.ts | 20 +++++++++ packages/core/src/config/config.ts | 3 +- .../core/src/config/projectRegistry.test.ts | 24 +++++++++++ packages/core/src/config/projectRegistry.ts | 7 +--- .../core/src/utils/workspaceContext.test.ts | 42 +++++++++++++++++++ packages/core/src/utils/workspaceContext.ts | 41 +++++++++++++++--- 6 files changed, 123 insertions(+), 14 deletions(-) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index ad8af8656cf..9a8e442d889 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3089,6 +3089,26 @@ describe('syncPlanModeTools', () => { }); }); +describe('isPathAllowed', () => { + it('should preserve casing when validating paths', () => { + const params: ConfigParameters = { + cwd: '/tmp', + targetDir: '/tmp/target', + model: DEFAULT_GEMINI_MODEL, + sessionId: 'test-session', + debugMode: false, + }; + const config = new Config(params); + const workspaceContext = config.getWorkspaceContext(); + const spy = vi.spyOn(workspaceContext, 'isPathWithinWorkspace'); + + const mixedCasePath = path.join('/tmp/target', 'SubDir', 'File.txt'); + config.isPathAllowed(mixedCasePath); + expect(spy).toHaveBeenCalledWith(expect.stringMatching(/File\.txt$/)); + expect(spy).toHaveBeenCalledWith(mixedCasePath); + }); +}); + describe('Model Persistence Bug Fix (#19864)', () => { const baseParams: ConfigParameters = { sessionId: 'test-session', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6262956c11e..a6c13641988 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { inspect } from 'node:util'; import process from 'node:process'; import type { @@ -2365,7 +2364,7 @@ export class Config implements McpContext { } catch { resolved = path.resolve(p); } - return os.platform() === 'win32' ? resolved.toLowerCase() : resolved; + return resolved; }; const resolvedPath = realpath(absolutePath); diff --git a/packages/core/src/config/projectRegistry.test.ts b/packages/core/src/config/projectRegistry.test.ts index a441de8b3e2..087737fe42e 100644 --- a/packages/core/src/config/projectRegistry.test.ts +++ b/packages/core/src/config/projectRegistry.test.ts @@ -107,6 +107,30 @@ describe('ProjectRegistry', () => { expect(id1).toBe(id2); }); + it('preserves path casing on Windows', async () => { + // Mock platform to match Windows behavior for this test + // Note: The actual code uses os.platform(), but for this unit test + // we rely on the implementation of consume/normalize not mutating case. + // Since we removed toLowerCase() from the implementation, we expect + // case to be preserved regardless of platform mock, but conceptually + // this safeguards the regression. + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + + const mixedCasePath = path.join(tempDir, 'MyProject'); + const id = await registry.getShortId(mixedCasePath); + // The getShortId method uses slugify() which converts to lowercase. + // So the ID should be 'myproject'. + // However, we want to ensure that the REGISTRY MAPPING preserves the case of the KEY (project path). + expect(id).toBe('myproject'); + + // Verify the mapping in the registry data uses the original case + const data = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + // helper normalizePath in this test file just resolves (no lowercase) + const normalizedKey = normalizePath(mixedCasePath); + expect(data.projects[normalizedKey]).toBe('myproject'); + }); + it('creates ownership markers in base directories', async () => { const registry = new ProjectRegistry(registryPath, [baseDir1, baseDir2]); await registry.initialize(); diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts index 725ea081f94..14b78cd97b3 100644 --- a/packages/core/src/config/projectRegistry.ts +++ b/packages/core/src/config/projectRegistry.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { lock } from 'proper-lockfile'; import { debugLogger } from '../utils/debugLogger.js'; @@ -69,11 +68,7 @@ export class ProjectRegistry { } private normalizePath(projectPath: string): string { - let resolved = path.resolve(projectPath); - if (os.platform() === 'win32') { - resolved = resolved.toLowerCase(); - } - return resolved; + return path.resolve(projectPath); } private async save(data: RegistryData): Promise { diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts index 8c29819a79e..c8c7a36cb43 100644 --- a/packages/core/src/utils/workspaceContext.test.ts +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -139,6 +139,48 @@ describe('WorkspaceContext with real filesystem', () => { expect(workspaceContext.isPathWithinWorkspace(invalidPath)).toBe(false); }); + it('should match paths case-insensitively on Windows', () => { + // Force win32 platform for this test + const originalPlatform = os.platform(); + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + // Setup: cwd is lowercase-ish, checkPath is MixedCase + // In the real fix, we normalize BOTH to lowercase for comparison. + // Let's assume cwd is '/tmp/project' + const lowerCwd = cwd.toLowerCase(); + // Ensure the directory exists for the test to pass validation + if (!fs.existsSync(lowerCwd)) { + fs.mkdirSync(lowerCwd, { recursive: true }); + } + const workspaceContext = new WorkspaceContext(lowerCwd); + + // A path that matches case-insensitively + const mixedCasePath = path.join(lowerCwd, 'SubDir', 'File.txt'); + expect(workspaceContext.isPathWithinWorkspace(mixedCasePath)).toBe( + true, + ); + + // Case 1: Root is lower, Check is Upper + const upperPath = path.join(lowerCwd.toUpperCase(), 'FILE.TXT'); + expect(workspaceContext.isPathWithinWorkspace(upperPath)).toBe(true); + + // Case 2: Root is Upper, Check is Lower + // Use a mixed case directory to simulate case-insensitivity without hitting root-level EACCES + const mixedRoot = path.join(cwd, 'MixedCaseRoot'); + if (!fs.existsSync(mixedRoot)) { + fs.mkdirSync(mixedRoot); + } + const workspaceContextMixed = new WorkspaceContext(mixedRoot); + const lowerPathValues = path.join(mixedRoot.toLowerCase(), 'file.txt'); + expect( + workspaceContextMixed.isPathWithinWorkspace(lowerPathValues), + ).toBe(true); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('should handle nested directories correctly', () => { const workspaceContext = new WorkspaceContext(cwd, [otherDir]); const nestedPath = path.join(cwd, 'deeply', 'nested', 'path', 'file.txt'); diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index dfb47ce3bec..94ccf2d8628 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -6,6 +6,7 @@ import { isNodeError } from '../utils/errors.js'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { debugLogger } from './debugLogger.js'; @@ -22,6 +23,7 @@ export interface AddDirectoriesResult { * in a single session. */ export class WorkspaceContext { + readonly targetDir: string; private directories = new Set(); private initialDirectories: Set; private readOnlyPaths = new Set(); @@ -32,10 +34,13 @@ export class WorkspaceContext { * @param targetDir The initial working directory (usually cwd) * @param additionalDirectories Optional array of additional directories to include */ - constructor( - readonly targetDir: string, - additionalDirectories: string[] = [], - ) { + constructor(targetDir: string, additionalDirectories: string[] = []) { + // Ensure targetDir is in original case from filesystem + try { + this.targetDir = fs.realpathSync(targetDir); + } catch { + this.targetDir = targetDir; + } this.addDirectory(targetDir); this.addDirectories(additionalDirectories); this.initialDirectories = new Set(this.directories); @@ -228,7 +233,26 @@ export class WorkspaceContext { */ private fullyResolvedPath(pathToCheck: string): string { try { - return fs.realpathSync(path.resolve(this.targetDir, pathToCheck)); + let resolvedInput = path.resolve(this.targetDir, pathToCheck); + + // On Windows, if pathToCheck is already absolute with incorrect casing, fix it + if (os.platform() === 'win32' && path.isAbsolute(pathToCheck)) { + try { + resolvedInput = fs.realpathSync(pathToCheck); + } catch { + // Normalize the case by matching against targetDir + const normalizedPathToCheck = pathToCheck.toLowerCase(); + const normalizedTargetDir = this.targetDir.toLowerCase(); + + if (normalizedPathToCheck.startsWith(normalizedTargetDir)) { + resolvedInput = + this.targetDir + + pathToCheck.substring(normalizedTargetDir.length); + } + } + } + + return fs.realpathSync(resolvedInput); } catch (e: unknown) { if ( isNodeError(e) && @@ -255,7 +279,12 @@ export class WorkspaceContext { pathToCheck: string, rootDirectory: string, ): boolean { - const relative = path.relative(rootDirectory, pathToCheck); + const normalizedRoot = + os.platform() === 'win32' ? rootDirectory.toLowerCase() : rootDirectory; + const normalizedPath = + os.platform() === 'win32' ? pathToCheck.toLowerCase() : pathToCheck; + + const relative = path.relative(normalizedRoot, normalizedPath); return ( !relative.startsWith(`..${path.sep}`) && relative !== '..' && From e2d919a989ae992b5caba73a7d328b7a423ef776 Mon Sep 17 00:00:00 2001 From: YC Lian Date: Mon, 9 Feb 2026 15:46:53 +0800 Subject: [PATCH 4/4] test(core): fix path case preservation verification on Windows --- .../core/src/config/projectRegistry.test.ts | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/core/src/config/projectRegistry.test.ts b/packages/core/src/config/projectRegistry.test.ts index 087737fe42e..846207a862b 100644 --- a/packages/core/src/config/projectRegistry.test.ts +++ b/packages/core/src/config/projectRegistry.test.ts @@ -108,27 +108,22 @@ describe('ProjectRegistry', () => { }); it('preserves path casing on Windows', async () => { - // Mock platform to match Windows behavior for this test - // Note: The actual code uses os.platform(), but for this unit test - // we rely on the implementation of consume/normalize not mutating case. - // Since we removed toLowerCase() from the implementation, we expect - // case to be preserved regardless of platform mock, but conceptually - // this safeguards the regression. + // Regression test: Mixed-case paths MUST be preserved in the registry (not lowercased). const registry = new ProjectRegistry(registryPath); await registry.initialize(); const mixedCasePath = path.join(tempDir, 'MyProject'); const id = await registry.getShortId(mixedCasePath); - // The getShortId method uses slugify() which converts to lowercase. - // So the ID should be 'myproject'. - // However, we want to ensure that the REGISTRY MAPPING preserves the case of the KEY (project path). + + // IDs are slugified (lowercase), but path keys in registry must remain case-sensitive. expect(id).toBe('myproject'); - // Verify the mapping in the registry data uses the original case + // Verify raw registry file data preserves path casing const data = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - // helper normalizePath in this test file just resolves (no lowercase) - const normalizedKey = normalizePath(mixedCasePath); - expect(data.projects[normalizedKey]).toBe('myproject'); + const expectedKey = path.resolve(mixedCasePath); + + expect(Object.keys(data.projects)).toContain(expectedKey); + expect(data.projects[expectedKey]).toBe('myproject'); }); it('creates ownership markers in base directories', async () => {