33 * Licensed under the MIT License. See License.txt in the project root for license information.
44 *--------------------------------------------------------------------------------------------*/
55
6- import { afterAll , beforeAll , beforeEach , describe , expect , it , suite , test } from 'vitest' ;
6+ import * as fs from 'fs' ;
7+ import { tmpdir } from 'os' ;
8+ import * as path from 'path' ;
9+ import { afterAll , afterEach , beforeAll , beforeEach , describe , expect , it , suite , test } from 'vitest' ;
710import { ConfigKey , IConfigurationService } from '../../../../platform/configuration/common/configurationService' ;
811import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService' ;
912import { ICustomInstructionsService } from '../../../../platform/customInstructions/common/customInstructionsService' ;
@@ -18,14 +21,15 @@ import { WorkingDirectory } from '../../../../platform/workspace/common/workingD
1821import { CancellationToken } from '../../../../util/vs/base/common/cancellation' ;
1922import { ResourceSet } from '../../../../util/vs/base/common/map' ;
2023import { posix } from '../../../../util/vs/base/common/path' ;
24+ import { isWindows } from '../../../../util/vs/base/common/platform' ;
2125import { URI } from '../../../../util/vs/base/common/uri' ;
2226import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors' ;
2327import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation' ;
2428import { ChatVariablesCollection , CustomizationsIndexId } from '../../../prompt/common/chatVariablesCollection' ;
2529import { IBuildPromptContext } from '../../../prompt/common/intents' ;
2630import { createExtensionUnitTestingServices } from '../../../test/node/services' ;
2731import { encodeUrlHostname } from '../../common/toolUtils' ;
28- import { assertFileOkForTool , inputGlobToPattern , isDirExternalAndNeedsConfirmation , isFileExternalAndNeedsConfirmation } from '../toolUtils' ;
32+ import { assertFileOkForTool , inputGlobToPattern , isDirExternalAndNeedsConfirmation , isExternalSymlinkedFile , isFileExternalAndNeedsConfirmation } from '../toolUtils' ;
2933
3034class TestIgnoreService extends NullIgnoreService {
3135 private readonly _ignoredUris = new Set < string > ( ) ;
@@ -159,7 +163,7 @@ suite('toolUtils - additionalReadAccessPaths', () => {
159163
160164 describe ( 'isFileExternalAndNeedsConfirmation' , ( ) => {
161165 test ( 'workspace file does not need confirmation' , async ( ) => {
162- expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/workspace/file.ts' ) ) ) . toBe ( false ) ;
166+ expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/workspace/file.ts' ) ) ) . toEqual ( { needsConfirmation : false , realPath : undefined } ) ;
163167 } ) ;
164168
165169 test ( 'external file that does not exist throws' , async ( ) => {
@@ -174,17 +178,17 @@ suite('toolUtils - additionalReadAccessPaths', () => {
174178
175179 test ( 'non-existent workspace file does not need confirmation' , async ( ) => {
176180 // Non-existent files within the workspace should also not trigger confirmation
177- expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/workspace/nonexistent.ts' ) ) ) . toBe ( false ) ;
181+ expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/workspace/nonexistent.ts' ) ) ) . toEqual ( { needsConfirmation : false , realPath : undefined } ) ;
178182 } ) ;
179183
180184 test ( 'external file under additional paths with readOnly does not need confirmation' , async ( ) => {
181185 await configService . setConfig ( ConfigKey . AdditionalReadAccessPaths , [ '/external' ] ) ;
182- expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/external/file.ts' ) , true ) ) . toBe ( false ) ;
186+ expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/external/file.ts' ) , true ) ) . toEqual ( { needsConfirmation : false , realPath : undefined } ) ;
183187 } ) ;
184188
185189 test ( 'nested file under additional paths with readOnly does not need confirmation' , async ( ) => {
186190 await configService . setConfig ( ConfigKey . AdditionalReadAccessPaths , [ '/external' ] ) ;
187- expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/external/deep/nested/file.ts' ) , true ) ) . toBe ( false ) ;
191+ expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/external/deep/nested/file.ts' ) , true ) ) . toEqual ( { needsConfirmation : false , realPath : undefined } ) ;
188192 } ) ;
189193
190194 test ( 'external file under additional paths without readOnly throws when file does not exist' , async ( ) => {
@@ -256,7 +260,7 @@ suite('toolUtils - additionalReadAccessPaths', () => {
256260 } ) ;
257261
258262 test ( 'isFileExternalAndNeedsConfirmation: file within workingDirectory is not external' , async ( ) => {
259- expect ( await invokeIsFileExternalWithWd ( URI . file ( '/my-project/src/file.ts' ) ) ) . toBe ( false ) ;
263+ expect ( await invokeIsFileExternalWithWd ( URI . file ( '/my-project/src/file.ts' ) ) ) . toEqual ( { needsConfirmation : false , realPath : undefined } ) ;
260264 } ) ;
261265
262266 test ( 'isFileExternalAndNeedsConfirmation: workspace file is external when workingDirectory is set' , async ( ) => {
@@ -383,7 +387,7 @@ suite('toolUtils - external file existence', () => {
383387 test ( 'external file that exists needs confirmation' , async ( ) => {
384388 // Mock an external file that actually exists
385389 mockFs . mockFile ( URI . file ( '/external/existing-file.ts' ) , 'content' ) ;
386- expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/external/existing-file.ts' ) ) ) . toBe ( true ) ;
390+ expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/external/existing-file.ts' ) ) ) . toEqual ( { needsConfirmation : true , realPath : undefined } ) ;
387391 } ) ;
388392
389393 test ( 'external file that does not exist throws' , async ( ) => {
@@ -395,12 +399,117 @@ suite('toolUtils - external file existence', () => {
395399 test ( 'workspace file does not need confirmation even if it exists' , async ( ) => {
396400 // Mock a workspace file
397401 mockFs . mockFile ( URI . file ( '/workspace/file.ts' ) , 'content' ) ;
398- expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/workspace/file.ts' ) ) ) . toBe ( false ) ;
402+ expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/workspace/file.ts' ) ) ) . toEqual ( { needsConfirmation : false , realPath : undefined } ) ;
399403 } ) ;
400404
401405 test ( 'workspace file does not need confirmation even if it does not exist' , async ( ) => {
402406 // Non-existent workspace file
403- expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/workspace/nonexistent.ts' ) ) ) . toBe ( false ) ;
407+ expect ( await invokeIsFileExternalAndNeedsConfirmation ( URI . file ( '/workspace/nonexistent.ts' ) ) ) . toEqual ( { needsConfirmation : false , realPath : undefined } ) ;
408+ } ) ;
409+ } ) ;
410+
411+ describe . skipIf ( isWindows ) ( 'isExternalSymlinkedFile' , ( ) => {
412+ let temporaryDirectory : string ;
413+ let workspaceDirectory : string ;
414+ let externalDirectory : string ;
415+
416+ beforeEach ( ( ) => {
417+ temporaryDirectory = fs . mkdtempSync ( path . join ( tmpdir ( ) , 'toolutils-symlink-' ) ) ;
418+ workspaceDirectory = fs . realpathSync ( fs . mkdtempSync ( path . join ( temporaryDirectory , 'workspace-' ) ) ) ;
419+ externalDirectory = fs . realpathSync ( fs . mkdtempSync ( path . join ( temporaryDirectory , 'external-' ) ) ) ;
420+ } ) ;
421+
422+ afterEach ( ( ) => {
423+ fs . rmSync ( temporaryDirectory , { recursive : true , force : true } ) ;
424+ } ) ;
425+
426+ function getFolder ( uri : URI ) : URI | undefined {
427+ const workspaceUri = URI . file ( workspaceDirectory ) ;
428+ return uri . fsPath === workspaceDirectory || uri . fsPath . startsWith ( `${ workspaceDirectory } ${ path . sep } ` ) ? workspaceUri : undefined ;
429+ }
430+
431+ async function invokeIsFileExternal ( uri : URI ) {
432+ const services = createExtensionUnitTestingServices ( ) ;
433+ services . define ( IWorkspaceService , new SyncDescriptor (
434+ TestWorkspaceService ,
435+ [ [ URI . file ( workspaceDirectory ) ] , [ ] ]
436+ ) ) ;
437+ const accessor = services . createTestingAccessor ( ) ;
438+ try {
439+ return await accessor . get ( IInstantiationService ) . invokeFunction ( acc => isFileExternalAndNeedsConfirmation ( acc , uri ) ) ;
440+ } finally {
441+ accessor . dispose ( ) ;
442+ }
443+ }
444+
445+ test ( 'returns true when the file symlink points outside the workspace' , async ( ) => {
446+ const externalFile = path . join ( externalDirectory , 'external.txt' ) ;
447+ const symlinkedFile = path . join ( workspaceDirectory , 'linked.txt' ) ;
448+ fs . writeFileSync ( externalFile , 'content' ) ;
449+ fs . symlinkSync ( externalFile , symlinkedFile ) ;
450+
451+ await expect ( isExternalSymlinkedFile ( URI . file ( symlinkedFile ) , getFolder ) ) . resolves . toBe ( true ) ;
452+ } ) ;
453+
454+ test ( 'returns the real path when a workspace symlink points outside the workspace' , async ( ) => {
455+ const externalFile = path . join ( externalDirectory , 'external.txt' ) ;
456+ const symlinkedFile = path . join ( workspaceDirectory , 'linked.txt' ) ;
457+ fs . writeFileSync ( externalFile , 'content' ) ;
458+ fs . symlinkSync ( externalFile , symlinkedFile ) ;
459+
460+ await expect ( invokeIsFileExternal ( URI . file ( symlinkedFile ) ) ) . resolves . toEqual ( {
461+ needsConfirmation : true ,
462+ realPath : URI . file ( externalFile ) ,
463+ } ) ;
464+ } ) ;
465+
466+ test ( 'returns true when a parent directory symlink points outside the workspace' , async ( ) => {
467+ const externalFile = path . join ( externalDirectory , 'external.txt' ) ;
468+ const symlinkedDirectory = path . join ( workspaceDirectory , 'linked' ) ;
469+ fs . writeFileSync ( externalFile , 'content' ) ;
470+ fs . symlinkSync ( externalDirectory , symlinkedDirectory , 'dir' ) ;
471+
472+ await expect ( isExternalSymlinkedFile ( URI . file ( path . join ( symlinkedDirectory , 'external.txt' ) ) , getFolder ) ) . resolves . toBe ( true ) ;
473+ } ) ;
474+
475+ test ( 'returns true for a nonexistent file under a parent directory symlink that points outside the workspace' , async ( ) => {
476+ const symlinkedDirectory = path . join ( workspaceDirectory , 'linked' ) ;
477+ fs . symlinkSync ( externalDirectory , symlinkedDirectory , 'dir' ) ;
478+
479+ await expect ( isExternalSymlinkedFile ( URI . file ( path . join ( symlinkedDirectory , 'missing.txt' ) ) , getFolder ) ) . resolves . toBe ( true ) ;
480+ } ) ;
481+
482+ test ( 'returns false when the symlink target is inside the workspace' , async ( ) => {
483+ const targetFile = path . join ( workspaceDirectory , 'target.txt' ) ;
484+ const symlinkedFile = path . join ( workspaceDirectory , 'linked.txt' ) ;
485+ fs . writeFileSync ( targetFile , 'content' ) ;
486+ fs . symlinkSync ( targetFile , symlinkedFile ) ;
487+
488+ await expect ( isExternalSymlinkedFile ( URI . file ( symlinkedFile ) , getFolder ) ) . resolves . toBe ( false ) ;
489+ } ) ;
490+
491+ test ( 'returns false when the file is not a symlink' , async ( ) => {
492+ const file = path . join ( workspaceDirectory , 'file.txt' ) ;
493+ fs . writeFileSync ( file , 'content' ) ;
494+
495+ await expect ( isExternalSymlinkedFile ( URI . file ( file ) , getFolder ) ) . resolves . toBe ( false ) ;
496+ } ) ;
497+
498+ test ( 'returns false when the workspace folder itself is symlinked' , async ( ) => {
499+ const symlinkedWorkspace = path . join ( temporaryDirectory , 'workspace-link' ) ;
500+ const workspaceFile = path . join ( workspaceDirectory , 'file.txt' ) ;
501+ fs . writeFileSync ( workspaceFile , 'content' ) ;
502+ fs . symlinkSync ( workspaceDirectory , symlinkedWorkspace , 'dir' ) ;
503+ const symlinkedWorkspaceUri = URI . file ( symlinkedWorkspace ) ;
504+
505+ await expect ( isExternalSymlinkedFile (
506+ URI . file ( path . join ( symlinkedWorkspace , 'file.txt' ) ) ,
507+ uri => uri . fsPath . startsWith ( `${ symlinkedWorkspace } ${ path . sep } ` ) ? symlinkedWorkspaceUri : undefined
508+ ) ) . resolves . toBe ( false ) ;
509+ } ) ;
510+
511+ test ( 'returns false when the file does not exist' , async ( ) => {
512+ await expect ( isExternalSymlinkedFile ( URI . file ( path . join ( workspaceDirectory , 'missing.txt' ) ) , getFolder ) ) . resolves . toBe ( false ) ;
404513 } ) ;
405514} ) ;
406515
0 commit comments