-
Notifications
You must be signed in to change notification settings - Fork 1.6k
paths: resolve windows short names #2573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| /*--------------------------------------------------------------------------------------------- | ||
| * Copyright (c) Microsoft Corporation. All rights reserved. | ||
| * Licensed under the MIT License. See License.txt in the project root for license information. | ||
| *--------------------------------------------------------------------------------------------*/ | ||
| import { execFileSync } from 'child_process'; | ||
| import type { Uri } from 'vscode'; | ||
| import { ILogService } from '../../../platform/log/common/logService'; | ||
| import { LRUCache } from '../../../util/vs/base/common/map'; | ||
| import { PromptPathRepresentationService } from '../common/promptPathRepresentationService'; | ||
|
|
||
| /** | ||
| * Pattern to detect 8.3 short filename segments (e.g., PROGRA~1, DOCUME~2) | ||
| * Format: 1-6 characters, followed by tilde and 1+ digits | ||
| */ | ||
| const SHORT_NAME_SEGMENT_PATTERN = /^[^~]{1,6}~\d+$/i; | ||
|
||
|
|
||
| /** | ||
| * Checks if a path segment looks like an 8.3 short filename | ||
| */ | ||
| function isShortNameSegment(segment: string): boolean { | ||
| // Remove extension if present for checking the base name | ||
| const dotIndex = segment.lastIndexOf('.'); | ||
| const baseName = dotIndex > 0 ? segment.substring(0, dotIndex) : segment; | ||
| return SHORT_NAME_SEGMENT_PATTERN.test(baseName); | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a Windows path contains any 8.3 short name segments | ||
| */ | ||
| function containsShortNameSegments(filePath: string): boolean { | ||
| const segments = filePath.split(/[\\/]/); | ||
| return segments.some(isShortNameSegment); | ||
| } | ||
|
|
||
| export class PromptPathRepresentationServiceNode extends PromptPathRepresentationService { | ||
| /** | ||
| * Cache mapping short path prefixes to their resolved long form. | ||
| * For example: "C:\PROGRA~1" -> "C:\Program Files" | ||
| * This allows reuse across files in the same short-named directory. | ||
| */ | ||
| private readonly _shortPrefixToLongPath = new LRUCache<string, string>(64); | ||
|
|
||
| constructor( | ||
| @ILogService private readonly _logService: ILogService, | ||
| ) { | ||
| super(); | ||
| } | ||
|
|
||
| override getFilePath(uri: Uri): string { | ||
| let filePath = super.getFilePath(uri); | ||
| if (this.isWindows()) { | ||
| filePath = this._tryResolveLongPath(filePath); | ||
| } | ||
| return filePath; | ||
| } | ||
|
|
||
| override resolveFilePath(filepath: string, predominantScheme?: string): Uri | undefined { | ||
| if (this.isWindows()) { | ||
| filepath = this._tryResolveLongPath(filepath); | ||
| } | ||
| return super.resolveFilePath(filepath, predominantScheme); | ||
| } | ||
|
|
||
| /** | ||
| * Attempts to resolve 8.3 short paths to their long form by resolving each | ||
| * short segment individually. This allows caching of directory prefixes | ||
| * for reuse across multiple files. | ||
| * | ||
| * For example, given paths: | ||
| * - `C:\PROGRA~1\foo.txt` | ||
| * - `C:\PROGRA~1\bar.txt` | ||
| * | ||
| * Only one shell call is needed because `C:\PROGRA~1` is cached after the first resolution. | ||
| */ | ||
| private _tryResolveLongPath(filePath: string): string { | ||
| if (!containsShortNameSegments(filePath)) { | ||
| return filePath; | ||
| } | ||
|
|
||
| // Detect the separator used in the path | ||
| const separator = filePath.includes('/') ? '/' : '\\'; | ||
| const segments = filePath.split(/[\\/]/); | ||
| const resolvedSegments: string[] = []; | ||
|
|
||
| for (let i = 0; i < segments.length; i++) { | ||
| const segment = segments[i]; | ||
| resolvedSegments.push(segment); | ||
|
|
||
| if (!isShortNameSegment(segment)) { | ||
| continue; | ||
| } | ||
|
|
||
| // Build the current prefix path (with short segment) | ||
| const shortPrefix = resolvedSegments.join(separator); | ||
| const cacheKey = shortPrefix.toLowerCase(); | ||
|
|
||
| // Check cache first | ||
| const cached = this._shortPrefixToLongPath.get(cacheKey); | ||
| if (cached !== undefined) { | ||
| // Replace resolvedSegments with the cached long path segments | ||
| resolvedSegments.length = 0; | ||
| resolvedSegments.push(...cached.split(/[\\/]/)); | ||
| continue; | ||
| } | ||
|
|
||
| // Resolve via shell | ||
| try { | ||
| const longPrefix = this._resolveLongPathViaShell(shortPrefix); | ||
| this._shortPrefixToLongPath.set(cacheKey, longPrefix); | ||
| // Replace resolvedSegments with the resolved long path segments | ||
| resolvedSegments.length = 0; | ||
| resolvedSegments.push(...longPrefix.split(/[\\/]/)); | ||
| } catch (error) { | ||
| const errorMessage = error instanceof Error ? error.message : String(error); | ||
| this._logService.warn(`Failed to resolve 8.3 short path "${shortPrefix}": ${errorMessage}`); | ||
| // Cache the short prefix to avoid repeated failures | ||
| this._shortPrefixToLongPath.set(cacheKey, shortPrefix); | ||
| } | ||
| } | ||
|
|
||
| return resolvedSegments.join(separator); | ||
| } | ||
|
|
||
| /** | ||
| * Uses PowerShell to resolve an 8.3 short path to its long form. | ||
| * Uses environment variables to safely pass the path without shell injection risks. | ||
| * Protected to allow overriding in tests. | ||
| */ | ||
| protected _resolveLongPathViaShell(shortPath: string): string { | ||
| const result = execFileSync('powershell.exe', [ | ||
| '-NoProfile', | ||
| '-NonInteractive', | ||
| '-NoLogo', | ||
| '-Command', | ||
| '(Get-Item $env:VSCODE_SHORT_PATH).FullName' | ||
| ], { | ||
| encoding: 'utf8', | ||
| timeout: 5000, | ||
| env: { | ||
| ...process.env, | ||
connor4312 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| VSCODE_SHORT_PATH: shortPath | ||
| }, | ||
| windowsHide: true | ||
| }); | ||
|
|
||
| return result.trim(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| /*--------------------------------------------------------------------------------------------- | ||
| * Copyright (c) Microsoft Corporation. All rights reserved. | ||
| * Licensed under the MIT License. See License.txt in the project root for license information. | ||
| *--------------------------------------------------------------------------------------------*/ | ||
|
|
||
| import { describe, expect, it } from 'vitest'; | ||
| import { isWindows } from '../../../../util/vs/base/common/platform'; | ||
| import { URI } from '../../../../util/vs/base/common/uri'; | ||
| import { TestLogService } from '../../../testing/common/testLogService'; | ||
| import { PromptPathRepresentationServiceNode } from '../../node/promptPathRepresentationServiceNode'; | ||
|
|
||
| /** | ||
| * Test subclass that mocks shell resolution and simulates Windows environment. | ||
| */ | ||
| class TestPromptPathRepresentationServiceNode extends PromptPathRepresentationServiceNode { | ||
| readonly shellCalls: string[] = []; | ||
| private readonly _resolutions: Map<string, string>; | ||
|
|
||
| constructor(resolutions: Record<string, string> = {}) { | ||
| super(new TestLogService()); | ||
| this._resolutions = new Map(Object.entries(resolutions)); | ||
| } | ||
|
|
||
| override isWindows(): boolean { | ||
| return true; | ||
| } | ||
|
|
||
| protected override _resolveLongPathViaShell(shortPath: string): string { | ||
| this.shellCalls.push(shortPath); | ||
| const resolved = this._resolutions.get(shortPath); | ||
| if (resolved === undefined) { | ||
| throw new Error(`No resolution configured for: ${shortPath}`); | ||
| } | ||
| return resolved; | ||
| } | ||
| } | ||
|
|
||
| describe('PromptPathRepresentationServiceNode', () => { | ||
| if (isWindows) { // on posix the fsPath transformations are different | ||
| describe('8.3 short path resolution', () => { | ||
| it('returns path unchanged when no short segments present', () => { | ||
| const service = new TestPromptPathRepresentationServiceNode(); | ||
| const result = service.resolveFilePath('C:\\Program Files\\app\\file.txt'); | ||
|
|
||
| expect(result?.fsPath).toBe('c:\\Program Files\\app\\file.txt'); | ||
| expect(service.shellCalls).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('resolves single short segment', () => { | ||
| const service = new TestPromptPathRepresentationServiceNode({ | ||
| 'C:\\PROGRA~1': 'C:\\Program Files', | ||
| }); | ||
|
|
||
| const result = service.resolveFilePath('C:\\PROGRA~1\\app\\file.txt'); | ||
|
|
||
| expect(result?.fsPath).toBe('c:\\Program Files\\app\\file.txt'); | ||
| expect(service.shellCalls).toEqual(['C:\\PROGRA~1']); | ||
| }); | ||
|
|
||
| it('resolves multiple short segments with separate shell calls', () => { | ||
| const service = new TestPromptPathRepresentationServiceNode({ | ||
| 'C:\\PROGRA~1': 'C:\\Program Files', | ||
| 'C:\\Program Files\\MICROS~1': 'C:\\Program Files\\Microsoft Office', | ||
| }); | ||
|
|
||
| const result = service.resolveFilePath('C:\\PROGRA~1\\MICROS~1\\file.txt'); | ||
|
|
||
| expect(result?.fsPath).toBe('c:\\Program Files\\Microsoft Office\\file.txt'); | ||
| expect(service.shellCalls).toEqual([ | ||
| 'C:\\PROGRA~1', | ||
| 'C:\\Program Files\\MICROS~1', | ||
| ]); | ||
| }); | ||
|
|
||
| it('caches resolved prefixes across multiple calls', () => { | ||
| const service = new TestPromptPathRepresentationServiceNode({ | ||
| 'C:\\PROGRA~1': 'C:\\Program Files', | ||
| }); | ||
|
|
||
| service.resolveFilePath('C:\\PROGRA~1\\foo.txt'); | ||
| service.resolveFilePath('C:\\PROGRA~1\\bar.txt'); | ||
| service.resolveFilePath('C:\\PROGRA~1\\subdir\\baz.txt'); | ||
|
|
||
| expect(service.shellCalls).toEqual(['C:\\PROGRA~1']); | ||
| }); | ||
|
|
||
| it('handles short segment with file extension', () => { | ||
| const service = new TestPromptPathRepresentationServiceNode({ | ||
| 'C:\\MYFILE~1.TXT': 'C:\\MyLongFileName.txt', | ||
| }); | ||
|
|
||
| const result = service.resolveFilePath('C:\\MYFILE~1.TXT'); | ||
|
|
||
| expect(result?.fsPath).toBe('c:\\MyLongFileName.txt'); | ||
| }); | ||
|
|
||
| it('falls back to original path on resolution error', () => { | ||
| const service = new TestPromptPathRepresentationServiceNode({}); | ||
|
|
||
| const result = service.resolveFilePath('C:\\PROGRA~1\\file.txt'); | ||
|
|
||
| expect(result?.fsPath).toBe('c:\\PROGRA~1\\file.txt'); | ||
| expect(service.shellCalls).toEqual(['C:\\PROGRA~1']); | ||
| }); | ||
|
|
||
| it('caches failed resolutions to avoid repeated attempts', () => { | ||
| const service = new TestPromptPathRepresentationServiceNode({}); | ||
|
|
||
| service.resolveFilePath('C:\\PROGRA~1\\foo.txt'); | ||
| service.resolveFilePath('C:\\PROGRA~1\\bar.txt'); | ||
|
|
||
| expect(service.shellCalls).toEqual(['C:\\PROGRA~1']); | ||
| }); | ||
|
|
||
| it('works with getFilePath for file URIs', () => { | ||
| const service = new TestPromptPathRepresentationServiceNode({ | ||
| 'c:\\PROGRA~1': 'c:\\Program Files', | ||
connor4312 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }); | ||
|
|
||
| const uri = URI.file('C:\\PROGRA~1\\app\\file.txt'); | ||
| const result = service.getFilePath(uri); | ||
|
|
||
| expect(result).toBe('c:\\Program Files\\app\\file.txt'); | ||
| }); | ||
| }); | ||
| } | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.