diff --git a/ext/vscode/src/commands/cmdUtil.ts b/ext/vscode/src/commands/cmdUtil.ts index dc251c28a2e..3f879e17780 100644 --- a/ext/vscode/src/commands/cmdUtil.ts +++ b/ext/vscode/src/commands/cmdUtil.ts @@ -9,9 +9,39 @@ import * as vscode from 'vscode'; import { createAzureDevCli } from '../utils/azureDevCli'; import { execAsync } from '../utils/execAsync'; import { fileExists } from '../utils/fileUtils'; +import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; const AzureYamlGlobPattern: vscode.GlobPattern = '**/[aA][zZ][uU][rR][eE].{[yY][aA][mM][lL],[yY][mM][lL]}'; +/** + * Validates that a URI has a valid fsPath for file system operations. + * Virtual file systems or certain VS Code contexts may not provide a valid fsPath. + * @param context The action context + * @param selectedFile The URI to validate + * @param selectedItem The original selected item (for error message context) + * @param commandName The name of the command being executed (for error message) + * @throws Error if the URI doesn't have a valid fsPath + */ +export function validateFileSystemUri( + context: IActionContext, + selectedFile: vscode.Uri | undefined, + selectedItem: vscode.Uri | TreeViewModel | undefined, + commandName: string +): void { + if (selectedFile && selectedFile.fsPath === undefined) { + context.errorHandling.suppressReportIssue = true; + const itemType = isTreeViewModel(selectedItem) ? 'TreeViewModel' : + isAzureDevCliModel(selectedItem) ? 'AzureDevCliModel' : + selectedItem ? 'vscode.Uri' : 'undefined'; + throw new Error(vscode.l10n.t( + "Unable to determine working folder for {0} command. The selected file has an unsupported URI scheme '{1}' (selectedItem type: {2}). Azure Developer CLI commands are not supported in virtual file systems. Please open a local folder or clone the repository locally.", + commandName, + selectedFile.scheme, + itemType + )); + } +} + // If the command was invoked with a specific file context, use the file context as the working directory for running Azure developer CLI commands. // Otherwise search the workspace for "azure.yaml" or "azure.yml" files. If only one is found, use it (i.e. its folder). If more than one is found, ask the user which one to use. // If at this point we still do not have a working directory, prompt the user to select one. diff --git a/ext/vscode/src/commands/deploy.ts b/ext/vscode/src/commands/deploy.ts index 73c87da3ccd..58cc1b61155 100644 --- a/ext/vscode/src/commands/deploy.ts +++ b/ext/vscode/src/commands/deploy.ts @@ -10,7 +10,7 @@ import { executeAsTask } from '../utils/executeAsTask'; import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliModel } from '../views/workspace/AzureDevCliModel'; import { AzureDevCliService } from '../views/workspace/AzureDevCliService'; -import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; +import { getAzDevTerminalTitle, getWorkingFolder, validateFileSystemUri } from './cmdUtil'; export async function deploy(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { let selectedModel: AzureDevCliModel | undefined; @@ -26,6 +26,10 @@ export async function deploy(context: IActionContext, selectedItem?: vscode.Uri // eslint-disable-next-line @typescript-eslint/no-non-null-assertion selectedFile = selectedItem!; } + + // Validate that selectedFile is valid for file system operations + validateFileSystemUri(context, selectedFile, selectedItem, 'deploy'); + const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/down.ts b/ext/vscode/src/commands/down.ts index e8db2e8a61a..0b36d10b3de 100644 --- a/ext/vscode/src/commands/down.ts +++ b/ext/vscode/src/commands/down.ts @@ -9,7 +9,7 @@ import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../views/workspace/AzureDevCliApplication'; -import { getAzDevTerminalTitle, getWorkingFolder, } from './cmdUtil'; +import { getAzDevTerminalTitle, getWorkingFolder, validateFileSystemUri, } from './cmdUtil'; /** * A tuple representing the arguments that must be passed to the `down` command when executed via {@link vscode.commands.executeCommand} @@ -28,6 +28,10 @@ export async function down(context: IActionContext, selectedItem?: vscode.Uri | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion selectedFile = selectedItem!; } + + // Validate that selectedFile is valid for file system operations + validateFileSystemUri(context, selectedFile, selectedItem, 'down'); + const workingFolder = await getWorkingFolder(context, selectedFile); const confirmPrompt = vscode.l10n.t("Are you sure you want to delete all this application's Azure resources? You can soft-delete certain resources like Azure KeyVaults to preserve their data, or permanently delete and purge them."); diff --git a/ext/vscode/src/commands/monitor.ts b/ext/vscode/src/commands/monitor.ts index 33aa29c5e93..1a309c13e57 100644 --- a/ext/vscode/src/commands/monitor.ts +++ b/ext/vscode/src/commands/monitor.ts @@ -8,7 +8,7 @@ import { createAzureDevCli } from '../utils/azureDevCli'; import { execAsync } from '../utils/execAsync'; import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../views/workspace/AzureDevCliApplication'; -import { getWorkingFolder } from './cmdUtil'; +import { getWorkingFolder, validateFileSystemUri } from './cmdUtil'; const MonitorChoices: IAzureQuickPickItem[] = [ { @@ -36,6 +36,10 @@ export async function monitor(context: IActionContext, selectedItem?: vscode.Uri // eslint-disable-next-line @typescript-eslint/no-non-null-assertion selectedFile = selectedItem!; } + + // Validate that selectedFile is valid for file system operations + validateFileSystemUri(context, selectedFile, selectedItem, 'monitor'); + const workingFolder = await getWorkingFolder(context, selectedFile); const monitorChoices = await context.ui.showQuickPick(MonitorChoices, { diff --git a/ext/vscode/src/commands/packageCli.ts b/ext/vscode/src/commands/packageCli.ts index 47ed67f6a83..7ecc6b3aaf4 100644 --- a/ext/vscode/src/commands/packageCli.ts +++ b/ext/vscode/src/commands/packageCli.ts @@ -10,7 +10,7 @@ import { executeAsTask } from '../utils/executeAsTask'; import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliModel } from '../views/workspace/AzureDevCliModel'; import { AzureDevCliService } from '../views/workspace/AzureDevCliService'; -import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; +import { getAzDevTerminalTitle, getWorkingFolder, validateFileSystemUri } from './cmdUtil'; // `package` is a reserved identifier so `packageCli` had to be used instead export async function packageCli(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { @@ -27,6 +27,10 @@ export async function packageCli(context: IActionContext, selectedItem?: vscode. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion selectedFile = selectedItem!; } + + // Validate that selectedFile is valid for file system operations + validateFileSystemUri(context, selectedFile, selectedItem, 'package'); + const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/pipeline.ts b/ext/vscode/src/commands/pipeline.ts index caacc4ffb5d..f802576b3e6 100644 --- a/ext/vscode/src/commands/pipeline.ts +++ b/ext/vscode/src/commands/pipeline.ts @@ -4,7 +4,7 @@ import { IActionContext } from '@microsoft/vscode-azext-utils'; import { composeArgs, withArg } from '@microsoft/vscode-processutils'; import * as vscode from 'vscode'; -import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; +import { getAzDevTerminalTitle, getWorkingFolder, validateFileSystemUri } from './cmdUtil'; import { TelemetryId } from '../telemetry/telemetryId'; import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; @@ -28,6 +28,10 @@ export async function pipelineConfig(context: IActionContext, selectedItem?: vsc // eslint-disable-next-line @typescript-eslint/no-non-null-assertion selectedFile = selectedItem!; } + + // Validate that selectedFile is valid for file system operations + validateFileSystemUri(context, selectedFile, selectedItem, 'pipeline config'); + const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/provision.ts b/ext/vscode/src/commands/provision.ts index cf18af770e0..e60844f4677 100644 --- a/ext/vscode/src/commands/provision.ts +++ b/ext/vscode/src/commands/provision.ts @@ -9,7 +9,7 @@ import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../views/workspace/AzureDevCliApplication'; -import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; +import { getAzDevTerminalTitle, getWorkingFolder, validateFileSystemUri } from './cmdUtil'; export async function provision(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { let selectedFile: vscode.Uri | undefined; @@ -21,6 +21,10 @@ export async function provision(context: IActionContext, selectedItem?: vscode.U // eslint-disable-next-line @typescript-eslint/no-non-null-assertion selectedFile = selectedItem!; } + + // Validate that selectedFile is valid for file system operations + validateFileSystemUri(context, selectedFile, selectedItem, 'provision'); + const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/restore.ts b/ext/vscode/src/commands/restore.ts index b2fed3c7945..1da01756f0e 100644 --- a/ext/vscode/src/commands/restore.ts +++ b/ext/vscode/src/commands/restore.ts @@ -10,7 +10,7 @@ import { executeAsTask } from '../utils/executeAsTask'; import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliModel } from '../views/workspace/AzureDevCliModel'; import { AzureDevCliService } from '../views/workspace/AzureDevCliService'; -import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; +import { getAzDevTerminalTitle, getWorkingFolder, validateFileSystemUri } from './cmdUtil'; export async function restore(context: IActionContext, selectedItem?: vscode.Uri | TreeViewModel): Promise { let selectedModel: AzureDevCliModel | undefined; @@ -26,6 +26,10 @@ export async function restore(context: IActionContext, selectedItem?: vscode.Uri // eslint-disable-next-line @typescript-eslint/no-non-null-assertion selectedFile = selectedItem!; } + + // Validate that selectedFile is valid for file system operations + validateFileSystemUri(context, selectedFile, selectedItem, 'restore'); + const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/commands/up.ts b/ext/vscode/src/commands/up.ts index e25e992031b..97c5662da0c 100644 --- a/ext/vscode/src/commands/up.ts +++ b/ext/vscode/src/commands/up.ts @@ -9,7 +9,7 @@ import { createAzureDevCli } from '../utils/azureDevCli'; import { executeAsTask } from '../utils/executeAsTask'; import { isAzureDevCliModel, isTreeViewModel, TreeViewModel } from '../utils/isTreeViewModel'; import { AzureDevCliApplication } from '../views/workspace/AzureDevCliApplication'; -import { getAzDevTerminalTitle, getWorkingFolder } from './cmdUtil'; +import { getAzDevTerminalTitle, getWorkingFolder, validateFileSystemUri } from './cmdUtil'; /** * A tuple representing the arguments that must be passed to the `up` command when executed via {@link vscode.commands.executeCommand} @@ -28,6 +28,10 @@ export async function up(context: IActionContext, selectedItem?: vscode.Uri | Tr // eslint-disable-next-line @typescript-eslint/no-non-null-assertion selectedFile = selectedItem!; } + + // Validate that selectedFile is valid for file system operations + validateFileSystemUri(context, selectedFile, selectedItem, 'up'); + const workingFolder = await getWorkingFolder(context, selectedFile); const azureCli = await createAzureDevCli(context); diff --git a/ext/vscode/src/test/suite/unit/provision.test.ts b/ext/vscode/src/test/suite/unit/provision.test.ts new file mode 100644 index 00000000000..0aa464b03d7 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/provision.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import { provision } from '../../../commands/provision'; +import { IActionContext } from '@microsoft/vscode-azext-utils'; + +suite('provision command', () => { + let sandbox: sinon.SinonSandbox; + let mockContext: IActionContext; + + setup(() => { + sandbox = sinon.createSandbox(); + + mockContext = { + errorHandling: { + suppressReportIssue: false + }, + telemetry: { + properties: {} + } + } as unknown as IActionContext; + }); + + teardown(() => { + sandbox.restore(); + }); + + test('throws error when selectedFile has undefined fsPath (virtual file system)', async () => { + // Mock the URI to ensure fsPath is undefined - simulates virtual file system + const mockUri = { + scheme: 'virtual', + fsPath: undefined, + authority: '', + path: '', + query: '', + fragment: '', + with: () => mockUri, + toString: () => 'virtual:/test' + } as unknown as vscode.Uri; + + try { + await provision(mockContext, mockUri); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + const errMessage = (error as Error).message; + expect(errMessage).to.include('Unable to determine working folder'); + expect(errMessage).to.include('virtual'); + expect(errMessage).to.include('vscode.Uri'); + expect(errMessage).to.include('virtual file systems'); + } + + expect(mockContext.errorHandling.suppressReportIssue).to.equal(true, 'Should suppress automatic issue reporting for user errors'); + }); +});