diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index cd42d3c7310..3a382abf132 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -160,6 +160,7 @@ "19gdw8": "Learn more", "1A1P5b": "Comment", "1AFYij": "This template contains one workflow, therefore it is classified as a Workflow.", + "1Ch1Od": "This name is reserved and cannot be used as a workflow name.", "1D047X": "Required. The value to convert to XML.", "1Fn5n+": "Required. The URI encoded string.", "1GWzEL": "No results found for the specified filters", @@ -2123,6 +2124,7 @@ "_19gdw8.comment": "Label to learn more about creating a new connection", "_1A1P5b.comment": "Comment Label", "_1AFYij.comment": "Content for the toaster for adding a single workflow.", + "_1Ch1Od.comment": "Error message when workflow name matches a reserved project folder name", "_1D047X.comment": "Required string parameter to be converted using xml function", "_1Fn5n+.comment": "Required URI encoded string parameter to be converted using uriComponentToBinary function", "_1GWzEL.comment": "Text displayed when no results are found in the browse grid", @@ -3940,6 +3942,7 @@ "_aGxYMY.comment": "Label to clear editor", "_aGyVJT.comment": "Required number parameter to get number of objects to remove for skip function", "_aI9W5L.comment": "Accessibility text to inform user this template does not contain connectors", + "_aIk72V.comment": "Warning message when workflow name collides with an existing workflow in the project", "_aJg1gl.comment": "error message for maximum invalid retry interval", "_aKf/r+.comment": "Run identifier not found error message", "_aP1wk9.comment": "Label for the description of a custom 'indexOf' function", @@ -5319,6 +5322,7 @@ "aGxYMY": "Clear editor", "aGyVJT": "Required. The number of objects to remove from the front of Collection. Must be a positive integer.", "aI9W5L": "This template does not have connectors", + "aIk72V": "A workflow with this name already exists in the selected project.", "aJg1gl": "Retry policy maximum interval is invalid, must match ISO 8601 duration format", "aKf/r+": "Specified run identifier not found", "aP1wk9": "Returns the first index of a value within a string (case-insensitive, invariant culture)", diff --git a/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts b/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts index f8a4c0718d8..a3c55a0f36a 100644 --- a/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts +++ b/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts @@ -7,7 +7,6 @@ import { localize } from '../../../localize'; import { getDependencyTimeout } from '../../utils/binaries'; import { getDependenciesVersion, ensureExtensionBundleHealthy } from '../../utils/bundleFeed'; import { setDotNetCommand } from '../../utils/dotnet/dotnet'; -import { executeCommand } from '../../utils/funcCoreTools/cpUtils'; import { setFunctionsCommand } from '../../utils/funcCoreTools/funcVersion'; import { installLSPSDK } from '../../utils/languageServerProtocol'; import { setNodeJsCommand } from '../../utils/nodeJs/nodeJsVersion'; @@ -35,7 +34,7 @@ export async function validateAndInstallBinaries(context: IActionContext) { async (progress, token) => { token.onCancellationRequested(() => { // Handle cancellation logic - executeCommand(ext.outputChannel, undefined, 'echo', 'validateAndInstallBinaries was canceled'); + ext.outputChannel.appendLog('validateAndInstallBinaries was canceled'); }); context.telemetry.properties.lastStep = 'getGlobalSetting'; diff --git a/apps/vs-code-designer/src/app/commands/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts b/apps/vs-code-designer/src/app/commands/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts index 0e15aa23f2f..b0ba47b1cce 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { validateFuncCoreToolsIsLatest } from '../validateFuncCoreToolsIsLatest'; -import { useBinariesDependencies, binariesExist } from '../../../utils/binaries'; +import { + useBinariesDependencies, + binariesExist, + verifyDependencyIntegrity, + getLatestFunctionCoreToolsVersion, +} from '../../../utils/binaries'; import { isDevContainerWorkspace } from '../../../utils/devContainerUtils'; +import { getLocalFuncCoreToolsVersion } from '../../../utils/funcCoreTools/funcVersion'; +import { installFuncCoreToolsBinaries } from '../installFuncCoreTools'; // Factory mocks required to break problematic import chains: // - binaries: circular dep with onboarding.ts @@ -10,6 +17,7 @@ vi.mock('../../../utils/binaries', () => ({ useBinariesDependencies: vi.fn(), binariesExist: vi.fn(), getLatestFunctionCoreToolsVersion: vi.fn(), + verifyDependencyIntegrity: vi.fn(), installBinaries: vi.fn(), getCpuArchitecture: vi.fn(), })); @@ -106,4 +114,33 @@ describe('validateFuncCoreToolsIsLatest', () => { expect(binariesExist).not.toHaveBeenCalled(); }); }); + + describe('on-disk integrity behavior', () => { + it('reinstalls when binaries exist but the on-disk integrity check fails', async () => { + vi.mocked(isDevContainerWorkspace).mockResolvedValue(false); + vi.mocked(useBinariesDependencies).mockResolvedValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(verifyDependencyIntegrity).mockReturnValue(false); + + await validateFuncCoreToolsIsLatest('4'); + + expect(verifyDependencyIntegrity).toHaveBeenCalledWith(expect.anything(), 'FuncCoreTools'); + expect(getLocalFuncCoreToolsVersion).not.toHaveBeenCalled(); + expect(getLatestFunctionCoreToolsVersion).not.toHaveBeenCalled(); + expect(installFuncCoreToolsBinaries).toHaveBeenCalled(); + }); + + it('does not reinstall when binaries exist, integrity passes, and the version is current', async () => { + vi.mocked(isDevContainerWorkspace).mockResolvedValue(false); + vi.mocked(useBinariesDependencies).mockResolvedValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(verifyDependencyIntegrity).mockReturnValue(true); + vi.mocked(getLocalFuncCoreToolsVersion).mockResolvedValue('4.0.0'); + vi.mocked(getLatestFunctionCoreToolsVersion).mockResolvedValue('4.0.0'); + + await validateFuncCoreToolsIsLatest('4'); + + expect(installFuncCoreToolsBinaries).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts index 79dd7106725..1f27c71bd94 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts @@ -5,7 +5,7 @@ import { PackageManager, funcDependencyName } from '../../../constants'; import { localize } from '../../../localize'; import { executeOnFunctions } from '../../functionsExtension/executeOnFunctionsExt'; -import { binariesExist, getLatestFunctionCoreToolsVersion, useBinariesDependencies } from '../../utils/binaries'; +import { binariesExist, getLatestFunctionCoreToolsVersion, useBinariesDependencies, verifyDependencyIntegrity } from '../../utils/binaries'; import { startAllDesignTimeApis, stopAllDesignTimeApis } from '../../utils/codeless/startDesignTimeApi'; import { getFunctionsCommand, getLocalFuncCoreToolsVersion, tryParseFuncVersion } from '../../utils/funcCoreTools/funcVersion'; import { getBrewPackageName } from '../../utils/funcCoreTools/getBrewPackageName'; @@ -38,14 +38,19 @@ async function validateFuncCoreToolsIsLatestBinaries(majorVersion?: string): Pro const binaries = await binariesExist(funcDependencyName); context.telemetry.properties.binariesExist = `${binaries}`; + // Deep-verify the installed files against the on-disk integrity manifest so a corrupt/incomplete + // install (e.g. a removed Function Host DLL) forces a wipe + reinstall instead of failing at startup. + const integrityValid = binaries ? verifyDependencyIntegrity(context, funcDependencyName) : false; + context.telemetry.properties.integrityValid = `${integrityValid}`; - const localVersion: string | null = binaries ? await getLocalFuncCoreToolsVersion() : null; + const hasValidBinaries = binaries && integrityValid; + const localVersion: string | null = hasValidBinaries ? await getLocalFuncCoreToolsVersion() : null; context.telemetry.properties.localVersion = localVersion ?? 'null'; - const newestVersion: string | undefined = binaries ? await getLatestFunctionCoreToolsVersion(context, majorVersion) : undefined; - const isOutdated = binaries && localVersion && newestVersion && semver.gt(newestVersion, localVersion); + const newestVersion: string | undefined = hasValidBinaries ? await getLatestFunctionCoreToolsVersion(context, majorVersion) : undefined; + const isOutdated = hasValidBinaries && localVersion && newestVersion && semver.gt(newestVersion, localVersion); - const shouldInstall = !binaries || localVersion === null || isOutdated; + const shouldInstall = !binaries || !integrityValid || localVersion === null || isOutdated; if (shouldInstall) { if (isOutdated) { diff --git a/apps/vs-code-designer/src/app/commands/nodeJs/__test__/validateNodeJsIsLatest.test.ts b/apps/vs-code-designer/src/app/commands/nodeJs/__test__/validateNodeJsIsLatest.test.ts index 4ef2adf5060..70ef552251b 100644 --- a/apps/vs-code-designer/src/app/commands/nodeJs/__test__/validateNodeJsIsLatest.test.ts +++ b/apps/vs-code-designer/src/app/commands/nodeJs/__test__/validateNodeJsIsLatest.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; import { nodeJsDependencyName } from '../../../../constants'; import { ext } from '../../../../extensionVariables'; -import { binariesExist, getLatestNodeJsVersion } from '../../../utils/binaries'; +import { binariesExist, getLatestNodeJsVersion, verifyDependencyIntegrity } from '../../../utils/binaries'; import { getLocalNodeJsVersion, getNodeJsCommand, setNodeJsCommand } from '../../../utils/nodeJs/nodeJsVersion'; import { getWorkspaceSetting, updateGlobalSetting } from '../../../utils/vsCodeConfig/settings'; import { installNodeJs } from '../installNodeJs'; @@ -38,6 +38,7 @@ vi.mock('../../../../extensionVariables', () => ({ vi.mock('../../../utils/binaries', () => ({ binariesExist: vi.fn(), getLatestNodeJsVersion: vi.fn(), + verifyDependencyIntegrity: vi.fn(() => true), })); vi.mock('../../../utils/nodeJs/nodeJsVersion', () => ({ @@ -81,6 +82,31 @@ describe('validateNodeJsIsLatest', () => { vi.mocked(vscode.window.showErrorMessage).mockResolvedValue(undefined); }); + it('reinstalls when binaries exist but the on-disk integrity check fails', async () => { + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(verifyDependencyIntegrity).mockReturnValue(false); + + await validateNodeJsIsLatest('20'); + + expect(verifyDependencyIntegrity).toHaveBeenCalledWith(contextRef.current, nodeJsDependencyName); + expect(installNodeJs).toHaveBeenCalledWith(contextRef.current, '20'); + expect(contextRef.current.telemetry.properties.integrityValid).toBe('false'); + expect(getLocalNodeJsVersion).not.toHaveBeenCalled(); + }); + + it('does not reinstall when binaries exist and the on-disk integrity check passes', async () => { + vi.mocked(getWorkspaceSetting).mockReturnValue(true); + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(verifyDependencyIntegrity).mockReturnValue(true); + vi.mocked(getLocalNodeJsVersion).mockResolvedValue('18.0.0'); + vi.mocked(getLatestNodeJsVersion).mockResolvedValue('18.0.0'); + + await validateNodeJsIsLatest('18'); + + expect(installNodeJs).not.toHaveBeenCalled(); + expect(contextRef.current.telemetry.properties.integrityValid).toBe('true'); + }); + it('installs without checking GitHub latest version when binaries are missing', async () => { vi.mocked(binariesExist).mockResolvedValue(false); diff --git a/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts b/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts index 6ac681a30fc..693e27bd291 100644 --- a/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts @@ -5,7 +5,7 @@ import { nodeJsDependencyName } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; -import { binariesExist, getLatestNodeJsVersion } from '../../utils/binaries'; +import { binariesExist, getLatestNodeJsVersion, verifyDependencyIntegrity } from '../../utils/binaries'; import { getLocalNodeJsVersion, getNodeJsCommand, setNodeJsCommand } from '../../utils/nodeJs/nodeJsVersion'; import { getWorkspaceSetting, updateGlobalSetting } from '../../utils/vsCodeConfig/settings'; import { installNodeJs } from './installNodeJs'; @@ -23,8 +23,12 @@ export async function validateNodeJsIsLatest(majorVersion?: string): Promise { }); }); - describe('downloadFileWithVerification', () => { + describe('downloadFileWithTransportVerification', () => { let context: IActionContext; const url = 'https://cdn.example.com/Microsoft.Azure.Functions.ExtensionBundle.Workflows.1.2.3_any-any.zip'; const destPath = '/tmp/dep.zip'; @@ -127,7 +132,7 @@ describe('binaries', () => { 'content-md5': 'XrY7u+Ae7tCTyyK7j1rNww==', }); - const result = await downloadFileWithVerification(context, url, destPath, dependencyName); + const result = await downloadFileWithTransportVerification(context, url, destPath, dependencyName); expect(result.actualSize).toBe(11); expect(result.expectedSize).toBe(11); @@ -141,7 +146,7 @@ describe('binaries', () => { mockWriter(); mockAxiosStream(['abc'], {}); - const result = await downloadFileWithVerification(context, url, destPath, dependencyName); + const result = await downloadFileWithTransportVerification(context, url, destPath, dependencyName); expect(result.expectedSize).toBeUndefined(); expect(result.expectedMd5).toBeUndefined(); @@ -158,7 +163,9 @@ describe('binaries', () => { mockWriter(); mockAxiosStream(['short'], { 'content-length': '999', 'content-md5': 'XrY7u+Ae7tCTyyK7j1rNww==' }); - await expect(downloadFileWithVerification(context, url, destPath, dependencyName, 3)).rejects.toBeInstanceOf(DownloadIntegrityError); + await expect(downloadFileWithTransportVerification(context, url, destPath, dependencyName, 3)).rejects.toBeInstanceOf( + DownloadIntegrityError + ); expect(axios.get).toHaveBeenCalledTimes(3); expect(context.telemetry.properties[`${dependencyName}DownloadAttempts`]).toBe('3'); }); @@ -171,7 +178,7 @@ describe('binaries', () => { mockWriter(); mockAxiosStream(['hello world'], { 'content-length': '11', 'content-md5': 'XrY7u+Ae7tCTyyK7j1rNww==' }); - const result = await downloadFileWithVerification(context, url, destPath, dependencyName, 3); + const result = await downloadFileWithTransportVerification(context, url, destPath, dependencyName, 3); expect(result.actualSize).toBe(11); expect(axios.get).toHaveBeenCalledTimes(2); @@ -182,7 +189,7 @@ describe('binaries', () => { const err = Object.assign(new Error('Not Found'), { response: { status: 404 } }); (axios.get as Mock).mockRejectedValueOnce(err); - await expect(downloadFileWithVerification(context, url, destPath, dependencyName, 3)).rejects.toBe(err); + await expect(downloadFileWithTransportVerification(context, url, destPath, dependencyName, 3)).rejects.toBe(err); expect(axios.get).toHaveBeenCalledTimes(1); }); @@ -192,7 +199,7 @@ describe('binaries', () => { mockWriter(); mockAxiosStream(['ok'], { 'content-length': '2' }); - const result = await downloadFileWithVerification(context, url, destPath, dependencyName, 3); + const result = await downloadFileWithTransportVerification(context, url, destPath, dependencyName, 3); expect(result.actualSize).toBe(2); expect(axios.get).toHaveBeenCalledTimes(2); }, 10000); @@ -201,7 +208,7 @@ describe('binaries', () => { mockWriter(); mockAxiosStream(['hello world'], { 'content-length': '11' }); - await downloadFileWithVerification(context, url, destPath, dependencyName); + await downloadFileWithTransportVerification(context, url, destPath, dependencyName); expect(axios.get).toHaveBeenCalledWith( url, @@ -224,7 +231,7 @@ describe('binaries', () => { 'content-length': '24942', }); - const result = await downloadFileWithVerification(context, url, destPath, dependencyName); + const result = await downloadFileWithTransportVerification(context, url, destPath, dependencyName); expect(result.actualSize).toBe(76680); expect(context.telemetry.properties[`${dependencyName}DownloadAttempts`]).toBe('1'); @@ -238,7 +245,9 @@ describe('binaries', () => { mockWriter(); mockAxiosStream(['short'], { 'content-length': '999' }); - await expect(downloadFileWithVerification(context, url, destPath, dependencyName, 3)).rejects.toBeInstanceOf(DownloadIntegrityError); + await expect(downloadFileWithTransportVerification(context, url, destPath, dependencyName, 3)).rejects.toBeInstanceOf( + DownloadIntegrityError + ); expect(axios.get).toHaveBeenCalledTimes(3); }, 10000); }); @@ -287,7 +296,7 @@ describe('binaries', () => { const result = await binariesExist(funcDependencyName); expect(result).toBe(false); - expect(executeCommand).toHaveBeenCalledWith(ext.outputChannel, undefined, 'echo', `FuncCoreTools binary is missing: ${funcBinary}`); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith(`FuncCoreTools binary is missing: ${funcBinary}`); }); it('should repair a stale Windows NodeJs binary path when node.exe exists', async () => { @@ -306,13 +315,8 @@ describe('binaries', () => { expect(result).toBe(true); expect(updateGlobalSetting).toHaveBeenCalledWith(nodeJsBinaryPathSettingKey, nodeExeBinary); - expect(executeCommand).toHaveBeenCalledWith( - ext.outputChannel, - undefined, - 'echo', - `${nodeJsDependencyName} binary path updated: ${nodeExeBinary}` - ); - expect(executeCommand).not.toHaveBeenCalledWith(ext.outputChannel, undefined, 'echo', `NodeJs binary is missing: ${staleNodeBinary}`); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith(`${nodeJsDependencyName} binary path updated: ${nodeExeBinary}`); + expect(ext.outputChannel.appendLog).not.toHaveBeenCalledWith(`NodeJs binary is missing: ${staleNodeBinary}`); }); it('should return false for a stale Windows NodeJs binary path when node.exe is also missing', async () => { @@ -330,7 +334,7 @@ describe('binaries', () => { expect(result).toBe(false); expect(updateGlobalSetting).not.toHaveBeenCalledWith(nodeJsBinaryPathSettingKey, expect.any(String)); - expect(executeCommand).toHaveBeenCalledWith(ext.outputChannel, undefined, 'echo', `NodeJs binary is missing: ${staleNodeBinary}`); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith(`NodeJs binary is missing: ${staleNodeBinary}`); }); it('should return false if useBinariesDependencies returns false', async () => { @@ -460,12 +464,7 @@ describe('binaries', () => { const result = await binariesExist(funcDependencyName); expect(result).toBe(false); - expect(executeCommand).toHaveBeenCalledWith( - ext.outputChannel, - undefined, - 'echo', - `FuncCoreTools binary is missing: ${funcBinaryNoExe}` - ); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith(`FuncCoreTools binary is missing: ${funcBinaryNoExe}`); }); it('should not try .exe fallback when path already ends with .exe', async () => { @@ -571,7 +570,7 @@ describe('binaries', () => { const result = binariesExistSync(funcDependencyName); expect(result).toBe(false); - expect(executeCommand).toHaveBeenCalledWith(ext.outputChannel, undefined, 'echo', `FuncCoreTools binary is missing: ${funcBinary}`); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith(`FuncCoreTools binary is missing: ${funcBinary}`); }); }); @@ -1192,4 +1191,347 @@ describe('binaries', () => { expect(() => verifyExtractedZip(zip, extractDir)).not.toThrow(); }); }); + + describe('getNodeJsSha256', () => { + let context: IActionContext; + + beforeEach(() => { + context = { telemetry: { properties: {} } } as IActionContext; + }); + + it('returns the checksum for the matching artifact file name', async () => { + const artifactFileName = 'node-v20.19.4-win-x64.zip'; + const downloadUrl = `https://nodejs.org/dist/v20.19.4/${artifactFileName}`; + const expectedHash = '1bf83e5958157d13673507349238236aec4f6efc95cf426cbe126a999a3e4c0b'; + (axios.get as Mock).mockResolvedValue({ + data: `deadbeef${'0'.repeat(56)} node-v20.19.4-linux-x64.tar.gz\n${expectedHash} ${artifactFileName}\n`, + }); + + const result = await getNodeJsSha256(context, downloadUrl); + + expect(result).toBe(expectedHash); + expect(context.telemetry.properties.nodeJsChecksumResolved).toBe('true'); + expect(axios.get).toHaveBeenCalledWith('https://nodejs.org/dist/v20.19.4/SHASUMS256.txt', expect.any(Object)); + }); + + it('returns undefined when the artifact is not listed', async () => { + (axios.get as Mock).mockResolvedValue({ data: `${'a'.repeat(64)} node-v20.19.4-linux-x64.tar.gz\n` }); + + const result = await getNodeJsSha256(context, 'https://nodejs.org/dist/v20.19.4/node-v20.19.4-win-x64.zip'); + + expect(result).toBeUndefined(); + expect(context.telemetry.properties.nodeJsChecksumResolved).toBe('false'); + }); + + it('returns undefined when the checksum source is unreachable', async () => { + (axios.get as Mock).mockRejectedValue(new Error('network down')); + + const result = await getNodeJsSha256(context, 'https://nodejs.org/dist/v20.19.4/node-v20.19.4-win-x64.zip'); + + expect(result).toBeUndefined(); + expect(context.telemetry.properties.nodeJsChecksumResolved).toBe('false'); + expect(context.telemetry.properties.nodeJsChecksumError).toContain('network down'); + }); + }); + + describe('getFuncCoreToolsSha256', () => { + let context: IActionContext; + + beforeEach(() => { + context = { telemetry: { properties: {} } } as IActionContext; + }); + + it('returns the checksum from the .sha2 sidecar', async () => { + const downloadUrl = + 'https://github.com/Azure/azure-functions-core-tools/releases/download/4.12.1/Azure.Functions.Cli.win-x64.4.12.1.zip'; + const expectedHash = 'dcad8149f8a7ab6020d47476d23e61e56550a3a2aef3c5ca1c37743e2fad446b'; + (axios.get as Mock).mockResolvedValue({ data: `${expectedHash}\n` }); + + const result = await getFuncCoreToolsSha256(context, downloadUrl); + + expect(result).toBe(expectedHash); + expect(context.telemetry.properties.funcChecksumResolved).toBe('true'); + expect(axios.get).toHaveBeenCalledWith(`${downloadUrl}.sha2`, expect.any(Object)); + }); + + it('returns undefined when the sidecar has no checksum', async () => { + (axios.get as Mock).mockResolvedValue({ data: 'not-a-hash' }); + + const result = await getFuncCoreToolsSha256(context, 'https://example.com/func.zip'); + + expect(result).toBeUndefined(); + expect(context.telemetry.properties.funcChecksumResolved).toBe('false'); + }); + + it('returns undefined when the sidecar request fails', async () => { + (axios.get as Mock).mockRejectedValue(new Error('404 not found')); + + const result = await getFuncCoreToolsSha256(context, 'https://example.com/func.zip'); + + expect(result).toBeUndefined(); + expect(context.telemetry.properties.funcChecksumResolved).toBe('false'); + expect(context.telemetry.properties.funcChecksumError).toContain('404'); + }); + }); + + describe('computeFileSha256', () => { + it('computes the streamed SHA256 hex digest of a file', async () => { + const readable = new EventEmitter(); + (fs.createReadStream as Mock).mockReturnValue(readable); + + const promise = computeFileSha256('/tmp/file.zip'); + readable.emit('data', Buffer.from('hello world')); + readable.emit('end'); + + // SHA256 of "hello world" + await expect(promise).resolves.toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'); + }); + }); + + describe('verifyDependencyIntegrity', () => { + let context: IActionContext; + + beforeEach(() => { + context = { telemetry: { properties: {} } } as IActionContext; + (getGlobalSetting as Mock).mockReturnValue('binariesLocation'); + }); + + it('returns true when every manifest file exists with the recorded size', async () => { + const manifest = { + dependencyName: funcDependencyName, + createdAt: '2024-01-01T00:00:00.000Z', + fileCount: 2, + files: [ + { path: 'func.exe', size: 100 }, + { path: 'in-proc8/func.dll', size: 200 }, + ], + }; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); + (fs.statSync as Mock).mockImplementation((filePath: string) => ({ + size: filePath.endsWith('func.exe') ? 100 : 200, + isFile: () => true, + })); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(true); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('passed'); + }); + + it('returns false and schedules reinstall when the manifest is missing', async () => { + (fs.existsSync as Mock).mockReturnValue(false); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('manifest-missing'); + }); + + it('returns false when a recorded file is missing on disk', async () => { + const manifest = { + dependencyName: funcDependencyName, + createdAt: '2024-01-01T00:00:00.000Z', + fileCount: 1, + files: [{ path: 'in-proc8/func.dll', size: 200 }], + }; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); + (fs.statSync as Mock).mockImplementation(() => { + throw new Error('ENOENT'); + }); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('file-missing'); + expect(context.telemetry.properties.FuncCoreToolsIntegrityMissingFile).toBe('in-proc8/func.dll'); + }); + + it('returns false when a recorded file has a different size', async () => { + const manifest = { + dependencyName: funcDependencyName, + createdAt: '2024-01-01T00:00:00.000Z', + fileCount: 1, + files: [{ path: 'func.exe', size: 100 }], + }; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); + (fs.statSync as Mock).mockReturnValue({ size: 999, isFile: () => true }); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('size-mismatch'); + }); + + it('returns false when a recorded file is no longer a regular file', async () => { + const manifest = { + dependencyName: funcDependencyName, + createdAt: '2024-01-01T00:00:00.000Z', + fileCount: 1, + files: [{ path: 'func.exe', size: 100 }], + }; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); + (fs.statSync as Mock).mockReturnValue({ size: 100, isFile: () => false }); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('not-a-file'); + expect(context.telemetry.properties.FuncCoreToolsIntegrityMismatchFile).toBe('func.exe'); + }); + + it('returns false when the manifest is not valid JSON', async () => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue('not-json'); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('error'); + }); + + it('returns false when the manifest files property is not an array', async () => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue( + JSON.stringify({ dependencyName: funcDependencyName, createdAt: '2024-01-01T00:00:00.000Z', fileCount: 0, files: 'nope' }) + ); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('manifest-invalid'); + }); + + it('returns false when the binaries location setting is not configured', async () => { + (getGlobalSetting as Mock).mockReturnValue(undefined); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('verifies a NodeJs install against its manifest', async () => { + const manifest = { + dependencyName: nodeJsDependencyName, + createdAt: '2024-01-01T00:00:00.000Z', + fileCount: 2, + files: [ + { path: 'node.exe', size: 300 }, + { path: 'node_modules/npm/bin/npm', size: 50 }, + ], + }; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); + (fs.statSync as Mock).mockImplementation((filePath: string) => ({ + size: filePath.endsWith('node.exe') ? 300 : 50, + isFile: () => true, + })); + + const result = await verifyDependencyIntegrity(context, nodeJsDependencyName); + + expect(result).toBe(true); + expect(context.telemetry.properties.NodeJsIntegrityResult).toBe('passed'); + }); + }); + + describe('writeDependencyIntegrityManifest', () => { + let context: IActionContext; + + beforeEach(() => { + context = { telemetry: { properties: {} } } as IActionContext; + }); + + it('writes a manifest with path + size for every file', () => { + const targetFolder = path.join('binariesLocation', funcDependencyName); + (fs.readdirSync as Mock).mockImplementation((dir: string) => { + if (dir === targetFolder) { + return [ + { name: 'func.exe', isDirectory: () => false, isFile: () => true }, + { name: 'README.md', isDirectory: () => false, isFile: () => true }, + { name: 'in-proc8', isDirectory: () => true, isFile: () => false }, + ]; + } + return [{ name: 'func.dll', isDirectory: () => false, isFile: () => true }]; + }); + (fs.statSync as Mock).mockImplementation((filePath: string) => ({ + size: filePath.endsWith('func.exe') ? 100 : 200, + })); + + writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(targetFolder, '.logicapps-integrity.json'), + expect.stringContaining('func.exe') + ); + const written = JSON.parse((fs.writeFileSync as Mock).mock.calls[0][1]); + expect(written.fileCount).toBe(3); + const funcExe = written.files.find((f: { path: string }) => f.path === 'func.exe'); + const funcDll = written.files.find((f: { path: string }) => f.path === 'in-proc8/func.dll'); + const readme = written.files.find((f: { path: string }) => f.path === 'README.md'); + expect(funcExe).toEqual({ path: 'func.exe', size: 100 }); + expect(funcDll).toEqual({ path: 'in-proc8/func.dll', size: 200 }); + expect(readme).toEqual({ path: 'README.md', size: 200 }); + expect(written.files.some((f: { sha256?: string }) => f.sha256 !== undefined)).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityManifestWritten).toBe('true'); + }); + + it('records size-only entries for NodeJs files', () => { + const targetFolder = path.join('binariesLocation', nodeJsDependencyName); + (fs.readdirSync as Mock).mockImplementation((dir: string) => { + if (dir === targetFolder) { + return [ + { name: 'node.exe', isDirectory: () => false, isFile: () => true }, + { name: 'npm.cmd', isDirectory: () => false, isFile: () => true }, + ]; + } + return []; + }); + (fs.statSync as Mock).mockReturnValue({ size: 42 }); + + writeDependencyIntegrityManifest(context, targetFolder, nodeJsDependencyName); + + const written = JSON.parse((fs.writeFileSync as Mock).mock.calls[0][1]); + const nodeExe = written.files.find((f: { path: string }) => f.path === 'node.exe'); + const npmCmd = written.files.find((f: { path: string }) => f.path === 'npm.cmd'); + expect(nodeExe).toEqual({ path: 'node.exe', size: 42 }); + expect(npmCmd).toEqual({ path: 'npm.cmd', size: 42 }); + }); + + it('records telemetry but does not throw when writing the manifest fails', () => { + const targetFolder = path.join('binariesLocation', funcDependencyName); + (fs.readdirSync as Mock).mockReturnValue([]); + (fs.writeFileSync as Mock).mockImplementation(() => { + throw new Error('disk full'); + }); + + expect(() => writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName)).not.toThrow(); + expect(context.telemetry.properties.FuncCoreToolsIntegrityManifestWritten).toBe('false'); + expect(context.telemetry.properties.FuncCoreToolsIntegrityManifestError).toContain('disk full'); + }); + + it('excludes the integrity manifest itself from the recorded file list', () => { + const targetFolder = path.join('binariesLocation', funcDependencyName); + (fs.readdirSync as Mock).mockImplementation((dir: string) => { + if (dir === targetFolder) { + return [ + { name: 'func.exe', isDirectory: () => false, isFile: () => true }, + { name: '.logicapps-integrity.json', isDirectory: () => false, isFile: () => true }, + ]; + } + return []; + }); + (fs.statSync as Mock).mockReturnValue({ size: 100 }); + + writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName); + + const written = JSON.parse((fs.writeFileSync as Mock).mock.calls[0][1]); + expect(written.fileCount).toBe(1); + expect(written.files.map((f: { path: string }) => f.path)).toEqual(['func.exe']); + expect(written.files).not.toContainEqual(expect.objectContaining({ path: '.logicapps-integrity.json' })); + }); + }); }); diff --git a/apps/vs-code-designer/src/app/utils/binaries.ts b/apps/vs-code-designer/src/app/utils/binaries.ts index df487d3e564..9d901fe2013 100644 --- a/apps/vs-code-designer/src/app/utils/binaries.ts +++ b/apps/vs-code-designer/src/app/utils/binaries.ts @@ -7,6 +7,8 @@ import { autoRuntimeDependenciesValidationAndInstallationSetting, autoRuntimeDependenciesPathSettingKey, dependencyTimeoutSettingKey, + dependencyMetadataRequestTimeoutMs, + dependencyIntegrityManifestFileName, dotnetDependencyName, funcPackageName, defaultLogicAppsFolder, @@ -30,6 +32,7 @@ import { type DownloadAttemptResult, downloadFileWithVerification as downloadFil import type { IActionContext } from '@microsoft/vscode-azext-utils'; import { Platform, type IGitHubReleaseInfo } from '@microsoft/vscode-extension-logic-apps'; import axios from 'axios'; +import { createHash } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -74,13 +77,13 @@ export async function downloadAndExtractDependency( const dependencyFileExtension = getCompressionFileExtension(downloadUrl); const dependencyFilePath = path.join(tempFolderPath, `${dependencyName}${dependencyFileExtension}`); - executeCommand(ext.outputChannel, undefined, 'echo', `Downloading dependency from: ${downloadUrl}`); + ext.outputChannel.appendLog(`Downloading dependency from: ${downloadUrl}`); fs.mkdirSync(tempFolderPath, { recursive: true }); fs.chmodSync(tempFolderPath, 0o777); let integrityResult: DownloadAttemptResult | undefined; try { - integrityResult = await downloadFileWithVerification(context, downloadUrl, dependencyFilePath, dependencyName); + integrityResult = await downloadFileWithTransportVerification(context, downloadUrl, dependencyFilePath, dependencyName); } catch (error) { const errorMessage = `Error downloading the ${dependencyName} file: ${error instanceof Error ? error.message : String(error)}`; vscode.window.showErrorMessage(errorMessage); @@ -99,9 +102,37 @@ export async function downloadAndExtractDependency( throw error; } - executeCommand(ext.outputChannel, undefined, 'echo', `Successfully downloaded ${dependencyName} dependency.`); + ext.outputChannel.appendLog(`Successfully downloaded ${dependencyName} dependency.`); fs.chmodSync(dependencyFilePath, 0o777); + // Verify the downloaded archive against the publisher's published SHA256 checksum before extracting. + // NodeJs (nodejs.org) and Functions Core Tools (GitHub releases) are not served from the Azure CDN + // that sets Content-MD5, so the transport-level check in downloadFileWithTransportVerification only validates + // size for them; the SHA256 check closes that gap. Unavailable checksums are non-blocking (skip). + const expectedSha256 = await resolveExpectedDependencySha256(context, downloadUrl, dependencyName); + if (expectedSha256) { + const actualSha256 = await computeFileSha256(dependencyFilePath); + context.telemetry.properties.expectedSha256 = expectedSha256; + context.telemetry.properties.actualSha256 = actualSha256; + if (actualSha256.toLowerCase() !== expectedSha256.toLowerCase()) { + const errorMessage = `Checksum verification failed for ${dependencyName}: expected SHA256 ${expectedSha256} but got ${actualSha256}.`; + vscode.window.showErrorMessage(errorMessage); + context.telemetry.properties.error = errorMessage; + try { + if (fs.existsSync(tempFolderPath)) { + fs.rmSync(tempFolderPath, { recursive: true, force: true }); + } + if (fs.existsSync(targetFolder)) { + fs.rmSync(targetFolder, { recursive: true, force: true }); + } + } catch { + // Best-effort cleanup; ignore secondary errors. + } + throw new Error(errorMessage); + } + ext.outputChannel.appendLog(localize('verifiedDownloadChecksum', 'Verified {0} download checksum.', dependencyName)); + } + try { // Extract to targetFolder if (dependencyName === dotnetDependencyName) { @@ -146,6 +177,11 @@ export async function downloadAndExtractDependency( await extractDependency(dependencyFilePath, targetFolder, dependencyName); } ext.outputChannel.appendLog(localize('successInstall', 'Successfully installed {0}', dependencyName)); + // Record an on-disk integrity manifest so a later validation pass can detect missing/corrupt + // files (e.g. a deleted DLL under the Function Host) and force a wipe + reinstall. + if (dependencyName === funcDependencyName || dependencyName === nodeJsDependencyName) { + writeDependencyIntegrityManifest(context, targetFolder, dependencyName); + } if (dependencyName === funcDependencyName) { // Add execute permissions for func and gozip binaries if (process.platform !== Platform.windows) { @@ -202,7 +238,7 @@ export async function downloadAndExtractDependency( // remove the temp folder. if (fs.existsSync(tempFolderPath)) { fs.rmSync(tempFolderPath, { recursive: true, force: true }); - executeCommand(ext.outputChannel, undefined, 'echo', `Removed ${tempFolderPath}`); + ext.outputChannel.appendLog(`Removed ${tempFolderPath}`); } } @@ -216,7 +252,7 @@ export async function downloadAndExtractDependency( * Thin wrapper that adds extension-host telemetry + log lines on top of the * vscode-free implementation in `./integrity`. */ -export async function downloadFileWithVerification( +export async function downloadFileWithTransportVerification( context: IActionContext, url: string, destPath: string, @@ -240,13 +276,15 @@ export async function downloadFileWithVerification( }, onAttempt: (attempt, error, willRetry) => { attemptsUsed = attempt; - executeCommand( - ext.outputChannel, - undefined, - 'echo', - `Download attempt ${attempt} for ${dependencyName} failed: ${ - error instanceof Error ? error.message : String(error) - }${willRetry ? ' — retrying.' : ''}` + ext.outputChannel.appendLog( + localize( + 'downloadAttemptFailed', + 'Download attempt {0} for {1} failed: {2}{3}', + attempt, + dependencyName, + error instanceof Error ? error.message : String(error), + willRetry ? ' — retrying.' : '' + ) ); }, }, @@ -404,6 +442,282 @@ export function getFunctionCoreToolsBinariesReleaseUrl(version: string, osPlatfo return `https://github.com/Azure/azure-functions-core-tools/releases/download/${version}/Azure.Functions.Cli.${osPlatform}-${arch}.${version}.zip`; } +/** + * Computes the SHA256 hex digest of a file using a stream so large archives are not buffered in memory. + * @param {string} filePath - Absolute path to the file to hash. + * @returns {Promise} The lowercase hex SHA256 digest. + */ +export async function computeFileSha256(filePath: string): Promise { + return await new Promise((resolve, reject) => { + const hash = createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('error', reject); + stream.on('data', (chunk: Buffer) => hash.update(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +} + +/** + * Resolves the published SHA256 checksum for a Node.js artifact from the official SHASUMS256.txt file. + * Returns undefined (non-blocking) when the checksum source is unreachable or the artifact is not listed. + * @param {IActionContext} context - Command context, used for telemetry. + * @param {string} downloadUrl - The Node.js archive download URL; SHASUMS256.txt lives in the same directory. + * @returns {Promise} The expected SHA256 hex digest, or undefined to skip verification. + */ +export async function getNodeJsSha256(context: IActionContext, downloadUrl: string): Promise { + try { + const artifactFileName = path.basename(downloadUrl); + const shasumsUrl = `${downloadUrl.slice(0, downloadUrl.length - artifactFileName.length)}SHASUMS256.txt`; + const response = await axios.get(shasumsUrl, { responseType: 'text', timeout: dependencyMetadataRequestTimeoutMs }); + const shasums: string = typeof response.data === 'string' ? response.data : String(response.data); + for (const line of shasums.split('\n')) { + const [hash, name] = line.trim().split(/\s+/); + if (name === artifactFileName && /^[a-f0-9]{64}$/i.test(hash)) { + context.telemetry.properties.nodeJsChecksumResolved = 'true'; + return hash; + } + } + context.telemetry.properties.nodeJsChecksumResolved = 'false'; + context.telemetry.properties.nodeJsChecksumError = `No checksum entry for ${artifactFileName}`; + } catch (error) { + context.telemetry.properties.nodeJsChecksumResolved = 'false'; + context.telemetry.properties.nodeJsChecksumError = error instanceof Error ? error.message : String(error); + } + return undefined; +} + +/** + * Resolves the published SHA256 checksum for an Azure Functions Core Tools archive from its `.sha2` sidecar. + * Returns undefined (non-blocking) when the checksum source is unreachable or malformed. + * @param {IActionContext} context - Command context, used for telemetry. + * @param {string} downloadUrl - The archive download URL; its `.sha2` sidecar holds the checksum. + * @returns {Promise} The expected SHA256 hex digest, or undefined to skip verification. + */ +export async function getFuncCoreToolsSha256(context: IActionContext, downloadUrl: string): Promise { + try { + const response = await axios.get(`${downloadUrl}.sha2`, { responseType: 'text', timeout: dependencyMetadataRequestTimeoutMs }); + const body: string = typeof response.data === 'string' ? response.data : String(response.data); + const match = body.match(/[a-f0-9]{64}/i); + if (match) { + context.telemetry.properties.funcChecksumResolved = 'true'; + return match[0]; + } + context.telemetry.properties.funcChecksumResolved = 'false'; + context.telemetry.properties.funcChecksumError = 'No checksum found in .sha2 sidecar'; + } catch (error) { + context.telemetry.properties.funcChecksumResolved = 'false'; + context.telemetry.properties.funcChecksumError = error instanceof Error ? error.message : String(error); + } + return undefined; +} + +/** + * Resolves the publisher-published SHA256 checksum for a downloaded dependency archive, deriving the + * checksum source from the download URL. NodeJs archives are verified against the official + * SHASUMS256.txt; Functions Core Tools archives against their `.sha2` sidecar. Returns undefined + * (skip verification) for any other dependency or when the checksum source is unavailable. + * @param {IActionContext} context - Command context, used for telemetry. + * @param {string} downloadUrl - The archive download URL. + * @param {string} dependencyName - The dependency name (e.g. NodeJs, FuncCoreTools). + * @returns {Promise} The expected SHA256 hex digest, or undefined to skip verification. + */ +export async function resolveExpectedDependencySha256( + context: IActionContext, + downloadUrl: string, + dependencyName: string +): Promise { + if (dependencyName === nodeJsDependencyName) { + return getNodeJsSha256(context, downloadUrl); + } + if (dependencyName === funcDependencyName) { + return getFuncCoreToolsSha256(context, downloadUrl); + } + return undefined; +} + +interface IntegrityManifestEntry { + path: string; + size: number; +} + +interface IntegrityManifest { + dependencyName: string; + createdAt: string; + fileCount: number; + files: IntegrityManifestEntry[]; +} + +/** + * Recursively lists every file under rootFolder (relative path + byte size), excluding the integrity + * manifest itself. Used to snapshot an install and later verify it is intact. + * @param {string} rootFolder - The dependency folder to walk. + * @returns {IntegrityManifestEntry[]} Relative file paths (posix separators) and their sizes. + */ +function listFilesWithSizes(rootFolder: string): IntegrityManifestEntry[] { + const entries: IntegrityManifestEntry[] = []; + const walk = (currentFolder: string): void => { + for (const entry of fs.readdirSync(currentFolder, { withFileTypes: true })) { + const absolutePath = path.join(currentFolder, entry.name); + if (entry.isDirectory()) { + walk(absolutePath); + } else if (entry.isFile()) { + if (path.relative(rootFolder, absolutePath) === dependencyIntegrityManifestFileName) { + continue; + } + const relativePath = path.relative(rootFolder, absolutePath).split(path.sep).join('/'); + entries.push({ path: relativePath, size: fs.statSync(absolutePath).size }); + } + } + }; + walk(rootFolder); + return entries; +} + +/** + * Writes an on-disk integrity manifest into a freshly installed dependency folder, recording every + * file's relative path and byte size. Best-effort: a failure to write the manifest is logged but + * never fails the install. + * @param {IActionContext} context - Command context, used for telemetry. + * @param {string} targetFolder - The dependency folder that was just extracted. + * @param {string} dependencyName - The dependency name (e.g. NodeJs, FuncCoreTools). + * @returns {void} + */ +export function writeDependencyIntegrityManifest(context: IActionContext, targetFolder: string, dependencyName: string): void { + try { + const files = listFilesWithSizes(targetFolder); + const manifest: IntegrityManifest = { + dependencyName, + createdAt: new Date().toISOString(), + fileCount: files.length, + files, + }; + const manifestPath = path.join(targetFolder, dependencyIntegrityManifestFileName); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + context.telemetry.properties[`${dependencyName}IntegrityManifestWritten`] = 'true'; + ext.outputChannel.appendLog( + localize('recordedIntegrityManifest', 'Recorded {0} on-disk integrity manifest ({1} files).', dependencyName, files.length) + ); + } catch (error) { + context.telemetry.properties[`${dependencyName}IntegrityManifestWritten`] = 'false'; + context.telemetry.properties[`${dependencyName}IntegrityManifestError`] = error instanceof Error ? error.message : String(error); + ext.outputChannel.appendLog( + localize( + 'recordedIntegrityManifestError', + 'Failed to record {0} on-disk integrity manifest: {1}', + dependencyName, + error instanceof Error ? error.message : String(error) + ) + ); + } +} + +/** + * Verifies an installed dependency against its on-disk integrity manifest. Returns false (which should + * trigger a wipe + reinstall) when the manifest is missing/unreadable, or any recorded file is missing, + * no longer a regular file, or its size no longer matches. A missing manifest is treated as a failure so + * that installs predating this check are re-established from a trusted, freshly-downloaded baseline. + * @param {IActionContext} context - Command context, used for telemetry. + * @param {string} dependencyName - The dependency name (e.g. NodeJs, FuncCoreTools). + * @returns {boolean} true when the install matches its manifest; false when it should be reinstalled. + */ +export function verifyDependencyIntegrity(context: IActionContext, dependencyName: string): boolean { + ext.outputChannel.appendLog(localize('validatingIntegrity', 'Validating {0} on-disk integrity...', dependencyName)); + try { + const binariesLocation = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); + if (!binariesLocation) { + return false; + } + const targetFolder = path.join(binariesLocation, dependencyName); + const manifestPath = path.join(targetFolder, dependencyIntegrityManifestFileName); + if (!fs.existsSync(manifestPath)) { + context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'manifest-missing'; + ext.outputChannel.appendLog( + localize( + 'integrityManifestMissing', + '{0} on-disk integrity manifest not found; scheduling reinstall to establish a trusted baseline.', + dependencyName + ) + ); + return false; + } + + const manifest: IntegrityManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (!manifest || !Array.isArray(manifest.files)) { + context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'manifest-invalid'; + ext.outputChannel.appendLog( + localize('integrityManifestInvalid', '{0} on-disk integrity manifest is invalid; scheduling reinstall.', dependencyName) + ); + return false; + } + + for (const file of manifest.files) { + const absolutePath = path.join(targetFolder, file.path); + let stat: fs.Stats; + try { + stat = fs.statSync(absolutePath); + } catch { + context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'file-missing'; + context.telemetry.properties[`${dependencyName}IntegrityMissingFile`] = file.path; + ext.outputChannel.appendLog( + localize( + 'integrityFileMissing', + '{0} on-disk integrity check FAILED: missing file "{1}". Scheduling reinstall.', + dependencyName, + file.path + ) + ); + return false; + } + if (!stat.isFile()) { + context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'not-a-file'; + context.telemetry.properties[`${dependencyName}IntegrityMismatchFile`] = file.path; + ext.outputChannel.appendLog( + localize( + 'integrityFileNotAFile', + '{0} on-disk integrity check FAILED: "{1}" is no longer a regular file. Scheduling reinstall.', + dependencyName, + file.path + ) + ); + return false; + } + if (stat.size !== file.size) { + context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'size-mismatch'; + context.telemetry.properties[`${dependencyName}IntegrityMismatchFile`] = file.path; + ext.outputChannel.appendLog( + localize( + 'integrityFileSizeMismatch', + '{0} on-disk integrity check FAILED: "{1}" changed size (expected {2}, found {3}). Scheduling reinstall.', + dependencyName, + file.path, + file.size, + stat.size + ) + ); + return false; + } + } + + context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'passed'; + ext.outputChannel.appendLog( + localize('integrityPassed', '{0} on-disk integrity check passed ({1} files verified).', dependencyName, manifest.files.length) + ); + return true; + } catch (error) { + context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'error'; + context.telemetry.properties[`${dependencyName}IntegrityError`] = error instanceof Error ? error.message : String(error); + ext.outputChannel.appendLog( + localize( + 'integrityErrored', + '{0} on-disk integrity check errored ({1}); scheduling reinstall.', + dependencyName, + error instanceof Error ? error.message : String(error) + ) + ); + return false; + } +} + export function getDotNetBinariesReleaseUrl(): string { return process.platform === Platform.windows ? 'https://dot.net/v1/dotnet-install.ps1' : 'https://dot.net/v1/dotnet-install.sh'; } @@ -473,7 +787,7 @@ function binariesExistFromSettings(dependencyName: string, updateMissingExeSetti const binariesExist = fs.existsSync(binariesPath); const expectedBinaryPath = binariesExist ? getExpectedBinaryPath(dependencyName) : undefined; - executeCommand(ext.outputChannel, undefined, 'echo', `${dependencyName} Binaries: ${binariesPath}`); + ext.outputChannel.appendLog(`${dependencyName} Binaries: ${binariesPath}`); if (expectedBinaryPath && !fs.existsSync(expectedBinaryPath)) { const repairedBinaryPath = getRepairableWindowsBinaryPath(dependencyName, binariesPath, expectedBinaryPath); if (repairedBinaryPath) { @@ -484,7 +798,7 @@ function binariesExistFromSettings(dependencyName: string, updateMissingExeSetti return true; } - executeCommand(ext.outputChannel, undefined, 'echo', `${dependencyName} binary is missing: ${expectedBinaryPath}`); + ext.outputChannel.appendLog(`${dependencyName} binary is missing: ${expectedBinaryPath}`); return false; } @@ -500,7 +814,7 @@ async function updateBinaryPathSetting(dependencyName: string, binaryPath: strin await updateGlobalSetting(nodeJsBinaryPathSettingKey, binaryPath); } - executeCommand(ext.outputChannel, undefined, 'echo', `${dependencyName} binary path updated: ${binaryPath}`); + ext.outputChannel.appendLog(`${dependencyName} binary path updated: ${binaryPath}`); } function getRepairableWindowsBinaryPath(dependencyName: string, binariesPath: string, expectedBinaryPath: string): string | undefined { @@ -535,7 +849,10 @@ interface ReadJsonFromUrlOptions { async function readJsonFromUrl(url: string, options: ReadJsonFromUrlOptions = {}): Promise { try { - const response = await axios.get(url); + // Bound the request so activation cannot hang indefinitely on a stalled + // version-lookup (the "Validating Runtime Dependency: NodeJS" hang); callers + // fall back to a bundled default version when the lookup fails. + const response = await axios.get(url, { timeout: dependencyMetadataRequestTimeoutMs }); if (response.status === 200) { return response.data; } @@ -1061,7 +1378,7 @@ async function extractDependency(dependencyFilePath: string, targetFolder: strin await executeCommand(ext.outputChannel, undefined, 'tar', '-xzvf', dependencyFilePath, '-C', targetFolder); } extractContainerFolder(targetFolder); - await executeCommand(ext.outputChannel, undefined, 'echo', `Extraction ${dependencyName} successfully completed.`); + ext.outputChannel.appendLog(`Extraction ${dependencyName} successfully completed.`); return; } catch (error) { lastError = error; diff --git a/apps/vs-code-designer/src/app/utils/dotnet/dotnet.ts b/apps/vs-code-designer/src/app/utils/dotnet/dotnet.ts index 11070e98513..8c7122cb659 100644 --- a/apps/vs-code-designer/src/app/utils/dotnet/dotnet.ts +++ b/apps/vs-code-designer/src/app/utils/dotnet/dotnet.ts @@ -207,7 +207,7 @@ export async function getLocalDotNetVersionFromBinaries(majorVersion?: string): const sdkFolders = files.filter((file) => file.isDirectory()).map((file) => file.name); const version = semver.maxSatisfying(sdkFolders, `~${majorVersion}`); if (version !== null) { - await executeCommand(ext.outputChannel, undefined, 'echo', 'Local binary .NET SDK version', version); + ext.outputChannel.appendLog(`Local binary .NET SDK version ${version}`); return version; } } diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index fec93d0d618..521c530047c 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -277,6 +277,10 @@ export const e2eStrictDependencyValidationSettingKey = 'e2eStrictDependencyValid export const useExperimentalExtensionBundleSettingKey = 'useExperimentalExtensionBundle'; export const experimentalExtensionBundleSourceUriSettingKey = 'experimentalExtensionBundleSourceUri'; export const experimentalExtensionBundleVersionSettingKey = 'experimentalExtensionBundleVersion'; +// Timeout (ms) for dependency version/metadata HTTP lookups so activation can't hang on a stalled request. +export const dependencyMetadataRequestTimeoutMs = 30 * 1000; +// On-disk integrity manifest written into each installed runtime dependency folder to detect corrupt/incomplete installs. +export const dependencyIntegrityManifestFileName = '.logicapps-integrity.json'; export const unitTestExplorer = 'unitTestExplorer'; export const verifyConnectionKeysSetting = 'verifyConnectionKeys'; export const useSmbDeployment = 'useSmbDeploymentForHybrid'; diff --git a/apps/vs-code-designer/test-setup.ts b/apps/vs-code-designer/test-setup.ts index 84792c82632..ed3c6e5af2b 100644 --- a/apps/vs-code-designer/test-setup.ts +++ b/apps/vs-code-designer/test-setup.ts @@ -76,7 +76,10 @@ vi.mock('fs', () => ({ mkdirSync: vi.fn(), chmodSync: vi.fn(), createWriteStream: vi.fn(), + createReadStream: vi.fn(), readdirSync: vi.fn(() => []), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), renameSync: vi.fn(), rmSync: vi.fn(), statSync: vi.fn(() => ({