From 56cc9c88627946931704c40e7a3fb07537ad19bc Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Tue, 7 Jul 2026 16:21:45 -0700 Subject: [PATCH 1/6] Harden runtime dependency validation: metadata timeout, SHA256 verification, on-disk integrity manifest - Add 30s timeout to readJsonFromUrl so NodeJs/Func/DotNet version lookups can no longer hang the extension on 'Validating Runtime Dependency: NodeJS'. - Verify SHA256 of downloaded NodeJs (SHASUMS256.txt) and FuncCoreTools (.sha2 sidecar) payloads, which upstream Content-MD5 checks don't cover for nodejs.org / GitHub release sources. - Write a per-dependency on-disk integrity manifest (.logicapps-integrity.json) at install time and verify every recorded file + size at validation time so a removed/corrupt file forces a wipe + reinstall instead of failing at runtime. - Wire integrity check into both NodeJs and FuncCoreTools validators; NodeJs validator now awaits binariesExist (fixes un-awaited Promise). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Localize/lang/strings.json | 4 + .../validateFuncCoreToolsIsLatest.test.ts | 1 + .../validateFuncCoreToolsIsLatest.ts | 8 +- .../__test__/validateNodeJsIsLatest.test.ts | 1 + .../commands/nodeJs/validateNodeJsIsLatest.ts | 8 +- .../src/app/utils/__test__/binaries.test.ts | 239 ++++++++++++++ .../src/app/utils/binaries.ts | 306 +++++++++++++++++- apps/vs-code-designer/src/constants.ts | 8 + apps/vs-code-designer/test-setup.ts | 3 + 9 files changed, 573 insertions(+), 5 deletions(-) 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/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts b/apps/vs-code-designer/src/app/commands/funcCoreTools/__test__/validateFuncCoreToolsIsLatest.test.ts index 0e15aa23f2f..378bf9e8437 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 @@ -10,6 +10,7 @@ vi.mock('../../../utils/binaries', () => ({ useBinariesDependencies: vi.fn(), binariesExist: vi.fn(), getLatestFunctionCoreToolsVersion: vi.fn(), + verifyDependencyIntegrity: vi.fn(), installBinaries: vi.fn(), getCpuArchitecture: vi.fn(), })); 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..7a7eaee4430 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,6 +38,10 @@ 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; context.telemetry.properties.localVersion = localVersion ?? 'null'; @@ -45,7 +49,7 @@ async function validateFuncCoreToolsIsLatestBinaries(majorVersion?: string): Pro const newestVersion: string | undefined = binaries ? await getLatestFunctionCoreToolsVersion(context, majorVersion) : undefined; const isOutdated = binaries && 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..1972f4873ac 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 @@ -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', () => ({ 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 { 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 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, '20.19.4', artifactFileName); + + 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, '20.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, '20.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', () => { + 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, + })); + + const result = 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', () => { + (fs.existsSync as Mock).mockReturnValue(false); + + const result = 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', () => { + 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 = 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', () => { + 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 }); + + const result = verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('size-mismatch'); + }); + + it('returns false when the manifest is not valid JSON', () => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue('not-json'); + + const result = verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('error'); + }); + }); + + describe('writeDependencyIntegrityManifest', () => { + let context: IActionContext; + + beforeEach(() => { + context = { telemetry: { properties: {} } } as IActionContext; + }); + + it('writes a manifest listing every extracted file and its size', () => { + 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: '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(2); + expect(written.files).toEqual( + expect.arrayContaining([ + { path: 'func.exe', size: 100 }, + { path: 'in-proc8/func.dll', size: 200 }, + ]) + ); + expect(context.telemetry.properties.FuncCoreToolsIntegrityManifestWritten).toBe('true'); + }); + + 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'); + }); + }); }); diff --git a/apps/vs-code-designer/src/app/utils/binaries.ts b/apps/vs-code-designer/src/app/utils/binaries.ts index df487d3e564..0380c1abc79 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'; @@ -102,6 +105,34 @@ export async function downloadAndExtractDependency( executeCommand(ext.outputChannel, undefined, 'echo', `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 downloadFileWithVerification 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); + } + executeCommand(ext.outputChannel, undefined, 'echo', `Verified ${dependencyName} download checksum.`); + } + 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) { @@ -404,6 +440,271 @@ 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(Uint8Array.from(chunk))); + 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} version - The Node.js version being downloaded (e.g. "20.19.4"). + * @param {string} artifactFileName - The artifact file name (e.g. "node-v20.19.4-win-x64.zip"). + * @returns {Promise} The expected SHA256 hex digest, or undefined to skip verification. + */ +export async function getNodeJsSha256(context: IActionContext, version: string, artifactFileName: string): Promise { + try { + const shasumsUrl = `https://nodejs.org/dist/v${version}/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) { + const match = downloadUrl.match(/nodejs\.org\/dist\/v([^/]+)\/(.+)$/); + if (match) { + return getNodeJsSha256(context, match[1], match[2]); + } + return undefined; + } + 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. 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). + */ +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 + * 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.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'; } @@ -535,7 +836,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; } diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index fec93d0d618..99ec58dee7b 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -277,6 +277,14 @@ export const e2eStrictDependencyValidationSettingKey = 'e2eStrictDependencyValid export const useExperimentalExtensionBundleSettingKey = 'useExperimentalExtensionBundle'; export const experimentalExtensionBundleSourceUriSettingKey = 'experimentalExtensionBundleSourceUri'; export const experimentalExtensionBundleVersionSettingKey = 'experimentalExtensionBundleVersion'; +// Max time (ms) to wait for a dependency metadata/HTTP response (e.g. latest version lookups) +// before failing fast and falling back to a bundled default version. Prevents activation from +// hanging on "Validating Runtime Dependency: NodeJS" when a version-lookup request stalls. +export const dependencyMetadataRequestTimeoutMs = 30 * 1000; +// File name of the on-disk integrity manifest written into each installed runtime dependency +// folder (NodeJs, FuncCoreTools). Records every extracted file + byte size so a later validation +// pass can detect a corrupt/incomplete install (e.g. a deleted DLL) and force a wipe + reinstall. +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(() => ({ From d95df6afbd6d433c00ae5277ebf4ea6b0b3e514d Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Tue, 7 Jul 2026 17:56:27 -0700 Subject: [PATCH 2/6] test(vs-code-designer): harden runtime-dependency integrity coverage Add tests locking in the reinstall-on-corruption behavior for the on-disk integrity manifest: - verifyDependencyIntegrity: manifest-invalid (files not an array), unconfigured binaries location, and a NodeJs happy path. - writeDependencyIntegrityManifest: excludes the manifest file itself. - validateNodeJsIsLatest / validateFuncCoreToolsIsLatest: binaries present but integrity invalid triggers reinstall; valid + current does not. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../validateFuncCoreToolsIsLatest.test.ts | 38 ++++++++++- .../__test__/validateNodeJsIsLatest.test.ts | 27 +++++++- .../src/app/utils/__test__/binaries.test.ts | 64 +++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) 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 378bf9e8437..e9d1287a852 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 @@ -107,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); + vi.mocked(getLocalFuncCoreToolsVersion).mockResolvedValue('4.0.0'); + vi.mocked(getLatestFunctionCoreToolsVersion).mockResolvedValue('4.0.0'); + + await validateFuncCoreToolsIsLatest('4'); + + expect(verifyDependencyIntegrity).toHaveBeenCalledWith(expect.anything(), 'FuncCoreTools'); + 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/nodeJs/__test__/validateNodeJsIsLatest.test.ts b/apps/vs-code-designer/src/app/commands/nodeJs/__test__/validateNodeJsIsLatest.test.ts index 1972f4873ac..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'; @@ -82,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/utils/__test__/binaries.test.ts b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts index d1267e82365..3db0b0d6289 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts @@ -1378,6 +1378,49 @@ describe('binaries', () => { expect(result).toBe(false); expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('error'); }); + + it('returns false when the manifest files property is not an array', () => { + (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 = 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', () => { + (getGlobalSetting as Mock).mockReturnValue(undefined); + + const result = verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('verifies a NodeJs install against its manifest', () => { + 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, + })); + + const result = verifyDependencyIntegrity(context, nodeJsDependencyName); + + expect(result).toBe(true); + expect(context.telemetry.properties.NodeJsIntegrityResult).toBe('passed'); + }); }); describe('writeDependencyIntegrityManifest', () => { @@ -1430,5 +1473,26 @@ describe('binaries', () => { 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).toEqual([{ path: 'func.exe', size: 100 }]); + expect(written.files).not.toContainEqual(expect.objectContaining({ path: '.logicapps-integrity.json' })); + }); }); }); From 5c118345470ca43c85e35b171a1b5c0c0012f881 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Tue, 7 Jul 2026 18:46:19 -0700 Subject: [PATCH 3/6] Add bounded SHA256 content verification for critical dependency binaries Extend the on-disk integrity manifest with an optional per-file sha256 for the primary executables (node.exe/node, func.exe/func/func.dll). These hashes are captured at install time right after the archive's CDN SHA256 was verified, so they are transitively CDN-anchored. On every startup the critical files are re-hashed and compared, catching silent same-size corruption that the size-only check misses, while the long tail stays size/existence-checked to avoid re-slowing activation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../validateFuncCoreToolsIsLatest.ts | 2 +- .../commands/nodeJs/validateNodeJsIsLatest.ts | 2 +- .../src/app/utils/__test__/binaries.test.ts | 150 ++++++++++++++---- .../src/app/utils/binaries.ts | 90 +++++++++-- 4 files changed, 203 insertions(+), 41 deletions(-) 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 7a7eaee4430..76e9f4c1124 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts @@ -40,7 +40,7 @@ async function validateFuncCoreToolsIsLatestBinaries(majorVersion?: string): Pro 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; + const integrityValid = binaries ? await verifyDependencyIntegrity(context, funcDependencyName) : false; context.telemetry.properties.integrityValid = `${integrityValid}`; const localVersion: string | null = binaries ? await getLocalFuncCoreToolsVersion() : null; 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 693e27bd291..0c2450435fd 100644 --- a/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts @@ -25,7 +25,7 @@ export async function validateNodeJsIsLatest(majorVersion?: string): Promise { describe('verifyDependencyIntegrity', () => { let context: IActionContext; + const stubHashStream = (content: string) => { + (fs.createReadStream as Mock).mockImplementation(() => { + const stream = new EventEmitter(); + process.nextTick(() => { + stream.emit('data', Buffer.from(content)); + stream.emit('end'); + }); + return stream; + }); + }; + beforeEach(() => { context = { telemetry: { properties: {} } } as IActionContext; (getGlobalSetting as Mock).mockReturnValue('binariesLocation'); }); - it('returns true when every manifest file exists with the recorded size', () => { + it('returns true when every manifest file exists with the recorded size', async () => { const manifest = { dependencyName: funcDependencyName, createdAt: '2024-01-01T00:00:00.000Z', @@ -1317,22 +1328,22 @@ describe('binaries', () => { size: filePath.endsWith('func.exe') ? 100 : 200, })); - const result = verifyDependencyIntegrity(context, funcDependencyName); + 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', () => { + it('returns false and schedules reinstall when the manifest is missing', async () => { (fs.existsSync as Mock).mockReturnValue(false); - const result = verifyDependencyIntegrity(context, funcDependencyName); + 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', () => { + it('returns false when a recorded file is missing on disk', async () => { const manifest = { dependencyName: funcDependencyName, createdAt: '2024-01-01T00:00:00.000Z', @@ -1345,14 +1356,14 @@ describe('binaries', () => { throw new Error('ENOENT'); }); - const result = verifyDependencyIntegrity(context, funcDependencyName); + 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', () => { + it('returns false when a recorded file has a different size', async () => { const manifest = { dependencyName: funcDependencyName, createdAt: '2024-01-01T00:00:00.000Z', @@ -1363,44 +1374,81 @@ describe('binaries', () => { (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); (fs.statSync as Mock).mockReturnValue({ size: 999 }); - const result = verifyDependencyIntegrity(context, funcDependencyName); + const result = await verifyDependencyIntegrity(context, funcDependencyName); expect(result).toBe(false); expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('size-mismatch'); }); - it('returns false when the manifest is not valid JSON', () => { + it('returns true when a critical executable content hash matches', async () => { + const manifest = { + dependencyName: funcDependencyName, + createdAt: '2024-01-01T00:00:00.000Z', + fileCount: 1, + files: [{ path: 'func.exe', size: 100, sha256: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9' }], + }; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); + (fs.statSync as Mock).mockReturnValue({ size: 100 }); + stubHashStream('hello world'); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(true); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('passed'); + }); + + it('returns false when a critical executable content hash no longer matches', async () => { + const manifest = { + dependencyName: funcDependencyName, + createdAt: '2024-01-01T00:00:00.000Z', + fileCount: 1, + files: [{ path: 'func.exe', size: 100, sha256: 'a'.repeat(64) }], + }; + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); + (fs.statSync as Mock).mockReturnValue({ size: 100 }); + stubHashStream('corrupted contents'); + + const result = await verifyDependencyIntegrity(context, funcDependencyName); + + expect(result).toBe(false); + expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('sha256-mismatch'); + 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 = verifyDependencyIntegrity(context, funcDependencyName); + 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', () => { + 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 = verifyDependencyIntegrity(context, funcDependencyName); + 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', () => { + it('returns false when the binaries location setting is not configured', async () => { (getGlobalSetting as Mock).mockReturnValue(undefined); - const result = verifyDependencyIntegrity(context, funcDependencyName); + const result = await verifyDependencyIntegrity(context, funcDependencyName); expect(result).toBe(false); expect(fs.existsSync).not.toHaveBeenCalled(); }); - it('verifies a NodeJs install against its manifest', () => { + it('verifies a NodeJs install against its manifest', async () => { const manifest = { dependencyName: nodeJsDependencyName, createdAt: '2024-01-01T00:00:00.000Z', @@ -1416,7 +1464,7 @@ describe('binaries', () => { size: filePath.endsWith('node.exe') ? 300 : 50, })); - const result = verifyDependencyIntegrity(context, nodeJsDependencyName); + const result = await verifyDependencyIntegrity(context, nodeJsDependencyName); expect(result).toBe(true); expect(context.telemetry.properties.NodeJsIntegrityResult).toBe('passed'); @@ -1426,16 +1474,28 @@ describe('binaries', () => { describe('writeDependencyIntegrityManifest', () => { let context: IActionContext; + const stubHashStream = () => { + (fs.createReadStream as Mock).mockImplementation((filePath: string) => { + const stream = new EventEmitter(); + process.nextTick(() => { + stream.emit('data', Buffer.from(String(filePath))); + stream.emit('end'); + }); + return stream; + }); + }; + beforeEach(() => { context = { telemetry: { properties: {} } } as IActionContext; }); - it('writes a manifest listing every extracted file and its size', () => { + it('writes a manifest with sizes for all files and sha256 for critical executables', async () => { 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 }, ]; } @@ -1444,37 +1504,66 @@ describe('binaries', () => { (fs.statSync as Mock).mockImplementation((filePath: string) => ({ size: filePath.endsWith('func.exe') ? 100 : 200, })); + stubHashStream(); - writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName); + await 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(2); - expect(written.files).toEqual( - expect.arrayContaining([ - { path: 'func.exe', size: 100 }, - { path: 'in-proc8/func.dll', size: 200 }, - ]) - ); + expect(written.fileCount).toBe(3); + // func.exe and in-proc8/func.dll are critical => hashed; README.md is size-only. + 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).toMatchObject({ size: 100 }); + expect(funcExe.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(funcDll).toMatchObject({ size: 200 }); + expect(funcDll.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(readme).toEqual({ path: 'README.md', size: 200 }); expect(context.telemetry.properties.FuncCoreToolsIntegrityManifestWritten).toBe('true'); + expect(context.telemetry.properties.FuncCoreToolsIntegrityHashedFiles).toBe('2'); + }); + + it('hashes only the critical NodeJs executable, not data files', async () => { + 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 }); + stubHashStream(); + + await 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.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(npmCmd).toEqual({ path: 'npm.cmd', size: 42 }); + expect(context.telemetry.properties.NodeJsIntegrityHashedFiles).toBe('1'); }); - it('records telemetry but does not throw when writing the manifest fails', () => { + it('records telemetry but does not throw when writing the manifest fails', async () => { 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(); + await expect(writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName)).resolves.toBeUndefined(); 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', () => { + it('excludes the integrity manifest itself from the recorded file list', async () => { const targetFolder = path.join('binariesLocation', funcDependencyName); (fs.readdirSync as Mock).mockImplementation((dir: string) => { if (dir === targetFolder) { @@ -1486,12 +1575,13 @@ describe('binaries', () => { return []; }); (fs.statSync as Mock).mockReturnValue({ size: 100 }); + stubHashStream(); - writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName); + await writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName); const written = JSON.parse((fs.writeFileSync as Mock).mock.calls[0][1]); expect(written.fileCount).toBe(1); - expect(written.files).toEqual([{ path: 'func.exe', size: 100 }]); + 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 0380c1abc79..dae746090af 100644 --- a/apps/vs-code-designer/src/app/utils/binaries.ts +++ b/apps/vs-code-designer/src/app/utils/binaries.ts @@ -180,7 +180,7 @@ export async function downloadAndExtractDependency( // 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); + await writeDependencyIntegrityManifest(context, targetFolder, dependencyName); } if (dependencyName === funcDependencyName) { // Add execute permissions for func and gozip binaries @@ -540,6 +540,7 @@ export async function resolveExpectedDependencySha256( interface IntegrityManifestEntry { path: string; size: number; + sha256?: string; } interface IntegrityManifest { @@ -549,6 +550,32 @@ interface IntegrityManifest { files: IntegrityManifestEntry[]; } +/** + * The primary executables per dependency whose content (not just presence/size) is SHA256-verified. + * These are the binaries that must launch at runtime (a corrupt node/func here breaks startup), so + * they get a bounded content check while the rest of the tree relies on the faster size/existence + * check. Matched by file basename (case-insensitive) so both Windows (.exe) and *nix layouts work. + */ +const criticalIntegrityFileNames: Record = { + [nodeJsDependencyName]: ['node.exe', 'node'], + [funcDependencyName]: ['func.exe', 'func', 'func.dll'], +}; + +/** + * Whether a manifest entry is a primary executable that should be SHA256 content-verified. + * @param {string} dependencyName - The dependency name (e.g. NodeJs, FuncCoreTools). + * @param {string} relativePath - The entry's relative path (posix separators). + * @returns {boolean} true when the file is in the dependency's critical-executable set. + */ +function isCriticalIntegrityFile(dependencyName: string, relativePath: string): boolean { + const criticalNames = criticalIntegrityFileNames[dependencyName]; + if (!criticalNames) { + return false; + } + const baseName = relativePath.split('/').pop()?.toLowerCase() ?? ''; + return criticalNames.some((name) => name.toLowerCase() === baseName); +} + /** * 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. @@ -576,15 +603,42 @@ function listFilesWithSizes(rootFolder: string): IntegrityManifestEntry[] { } /** - * Writes an on-disk integrity manifest into a freshly installed dependency folder. Best-effort: a - * failure to write the manifest is logged but never fails the install. + * Writes an on-disk integrity manifest into a freshly installed dependency folder, recording every + * file's size plus a SHA256 content hash for the primary executables. Best-effort: a failure to write + * the manifest (or to hash an individual file) 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 {Promise} Resolves once the manifest has been written (or the failure logged). */ -export function writeDependencyIntegrityManifest(context: IActionContext, targetFolder: string, dependencyName: string): void { +export async function writeDependencyIntegrityManifest( + context: IActionContext, + targetFolder: string, + dependencyName: string +): Promise { try { const files = listFilesWithSizes(targetFolder); + // Content-hash only the primary executables (bounded, so startup stays fast). Best-effort per + // file: a hashing failure leaves sha256 undefined so the file falls back to the size check. + let hashedCount = 0; + for (const file of files) { + if (isCriticalIntegrityFile(dependencyName, file.path)) { + try { + file.sha256 = await computeFileSha256(path.join(targetFolder, file.path)); + hashedCount += 1; + } catch (hashError) { + ext.outputChannel.appendLog( + localize( + 'integrityHashSkip', + 'Could not hash {0} file "{1}" for the integrity manifest: {2}', + dependencyName, + file.path, + hashError instanceof Error ? hashError.message : String(hashError) + ) + ); + } + } + } const manifest: IntegrityManifest = { dependencyName, createdAt: new Date().toISOString(), @@ -594,6 +648,7 @@ export function writeDependencyIntegrityManifest(context: IActionContext, target const manifestPath = path.join(targetFolder, dependencyIntegrityManifestFileName); fs.writeFileSync(manifestPath, JSON.stringify(manifest)); context.telemetry.properties[`${dependencyName}IntegrityManifestWritten`] = 'true'; + context.telemetry.properties[`${dependencyName}IntegrityHashedFiles`] = `${hashedCount}`; ext.outputChannel.appendLog( localize('recordedIntegrityManifest', 'Recorded {0} on-disk integrity manifest ({1} files).', dependencyName, files.length) ); @@ -613,14 +668,15 @@ export function writeDependencyIntegrityManifest(context: IActionContext, target /** * 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 - * 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. + * trigger a wipe + reinstall) when the manifest is missing/unreadable, any recorded file is missing or + * its size no longer matches, or a primary executable's SHA256 content hash 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. + * @returns {Promise} true when the install matches its manifest; false when it should be reinstalled. */ -export function verifyDependencyIntegrity(context: IActionContext, dependencyName: string): boolean { +export async function verifyDependencyIntegrity(context: IActionContext, dependencyName: string): Promise { ext.outputChannel.appendLog(localize('validatingIntegrity', 'Validating {0} on-disk integrity...', dependencyName)); try { const binariesLocation = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); @@ -683,6 +739,22 @@ export function verifyDependencyIntegrity(context: IActionContext, dependencyNam ); return false; } + if (file.sha256) { + const actualSha256 = await computeFileSha256(absolutePath); + if (actualSha256 !== file.sha256) { + context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'sha256-mismatch'; + context.telemetry.properties[`${dependencyName}IntegrityMismatchFile`] = file.path; + ext.outputChannel.appendLog( + localize( + 'integrityFileSha256Mismatch', + '{0} on-disk integrity check FAILED: "{1}" content hash changed. Scheduling reinstall.', + dependencyName, + file.path + ) + ); + return false; + } + } } context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'passed'; From aaf0973f0d57cfcfe5b17a649e2fec2b8f84e78f Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Tue, 7 Jul 2026 20:38:15 -0700 Subject: [PATCH 4/6] Address PR review: avoid hash chunk copy and reject non-regular-file entries computeFileSha256 now feeds the Buffer chunk straight into hash.update instead of copying it into a new Uint8Array per chunk, removing avoidable allocation/CPU overhead when hashing large archives. verifyDependencyIntegrity now fails fast (and schedules reinstall) when a recorded manifest entry is no longer a regular file (e.g. replaced by a directory or symlink), reported via the new 'not-a-file' integrity result. Adds a regression test and updates existing statSync stubs to model isFile(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/app/utils/__test__/binaries.test.ts | 26 ++++++++++++++++--- .../src/app/utils/binaries.ts | 15 ++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts index 1d28d24abea..f8d3af2c7b5 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts @@ -1326,6 +1326,7 @@ describe('binaries', () => { (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); @@ -1372,7 +1373,7 @@ describe('binaries', () => { }; (fs.existsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); - (fs.statSync as Mock).mockReturnValue({ size: 999 }); + (fs.statSync as Mock).mockReturnValue({ size: 999, isFile: () => true }); const result = await verifyDependencyIntegrity(context, funcDependencyName); @@ -1380,6 +1381,24 @@ describe('binaries', () => { 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 true when a critical executable content hash matches', async () => { const manifest = { dependencyName: funcDependencyName, @@ -1389,7 +1408,7 @@ describe('binaries', () => { }; (fs.existsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); - (fs.statSync as Mock).mockReturnValue({ size: 100 }); + (fs.statSync as Mock).mockReturnValue({ size: 100, isFile: () => true }); stubHashStream('hello world'); const result = await verifyDependencyIntegrity(context, funcDependencyName); @@ -1407,7 +1426,7 @@ describe('binaries', () => { }; (fs.existsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); - (fs.statSync as Mock).mockReturnValue({ size: 100 }); + (fs.statSync as Mock).mockReturnValue({ size: 100, isFile: () => true }); stubHashStream('corrupted contents'); const result = await verifyDependencyIntegrity(context, funcDependencyName); @@ -1462,6 +1481,7 @@ describe('binaries', () => { (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); diff --git a/apps/vs-code-designer/src/app/utils/binaries.ts b/apps/vs-code-designer/src/app/utils/binaries.ts index dae746090af..f923ee18be4 100644 --- a/apps/vs-code-designer/src/app/utils/binaries.ts +++ b/apps/vs-code-designer/src/app/utils/binaries.ts @@ -450,7 +450,7 @@ export async function computeFileSha256(filePath: string): Promise { const hash = createHash('sha256'); const stream = fs.createReadStream(filePath); stream.on('error', reject); - stream.on('data', (chunk: Buffer) => hash.update(Uint8Array.from(chunk))); + stream.on('data', (chunk: Buffer) => hash.update(chunk)); stream.on('end', () => resolve(hash.digest('hex'))); }); } @@ -724,6 +724,19 @@ export async function verifyDependencyIntegrity(context: IActionContext, depende ); 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; From 9c676eb3ea32203ad12203b035451fa2eaa227c6 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 8 Jul 2026 17:34:20 -0700 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20address=20PR=20review=20(Andrew?= =?UTF-8?q?)=20=E2=80=94=20appendLog,=20rename=20download=20wrapper,=20dro?= =?UTF-8?q?p=20URL=20regex=20and=20per-file=20integrity=20hashing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace new echo executeCommand logs with ext.outputChannel.appendLog (localized) - Rename downloadFileWithVerification -> downloadFileWithTransportVerification (transport-only) - Remove URL regex in resolveExpectedDependencySha256; derive SHASUMS URL + artifact name from downloadUrl - Drop per-file SHA256 from the on-disk integrity manifest (keep size+existence checks); manifest write/verify are now synchronous - Trim verbose constants.ts comments; zero-copy hash.update view Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../validateFuncCoreToolsIsLatest.ts | 2 +- .../commands/nodeJs/validateNodeJsIsLatest.ts | 2 +- .../src/app/utils/__test__/binaries.test.ts | 123 +++++------------ .../src/app/utils/binaries.ts | 130 ++++-------------- apps/vs-code-designer/src/constants.ts | 8 +- 5 files changed, 64 insertions(+), 201 deletions(-) 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 76e9f4c1124..7a7eaee4430 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts @@ -40,7 +40,7 @@ async function validateFuncCoreToolsIsLatestBinaries(majorVersion?: string): Pro 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 ? await verifyDependencyIntegrity(context, funcDependencyName) : false; + const integrityValid = binaries ? verifyDependencyIntegrity(context, funcDependencyName) : false; context.telemetry.properties.integrityValid = `${integrityValid}`; const localVersion: string | null = binaries ? await getLocalFuncCoreToolsVersion() : null; 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 0c2450435fd..693e27bd291 100644 --- a/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/nodeJs/validateNodeJsIsLatest.ts @@ -25,7 +25,7 @@ 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'; @@ -132,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); @@ -146,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(); @@ -163,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'); }); @@ -176,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); @@ -187,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); }); @@ -197,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); @@ -206,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, @@ -229,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'); @@ -243,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); }); @@ -1207,12 +1211,13 @@ describe('binaries', () => { 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, '20.19.4', artifactFileName); + const result = await getNodeJsSha256(context, downloadUrl); expect(result).toBe(expectedHash); expect(context.telemetry.properties.nodeJsChecksumResolved).toBe('true'); @@ -1222,7 +1227,7 @@ describe('binaries', () => { 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, '20.19.4', 'node-v20.19.4-win-x64.zip'); + 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'); @@ -1231,7 +1236,7 @@ describe('binaries', () => { it('returns undefined when the checksum source is unreachable', async () => { (axios.get as Mock).mockRejectedValue(new Error('network down')); - const result = await getNodeJsSha256(context, '20.19.4', 'node-v20.19.4-win-x64.zip'); + 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'); @@ -1296,17 +1301,6 @@ describe('binaries', () => { describe('verifyDependencyIntegrity', () => { let context: IActionContext; - const stubHashStream = (content: string) => { - (fs.createReadStream as Mock).mockImplementation(() => { - const stream = new EventEmitter(); - process.nextTick(() => { - stream.emit('data', Buffer.from(content)); - stream.emit('end'); - }); - return stream; - }); - }; - beforeEach(() => { context = { telemetry: { properties: {} } } as IActionContext; (getGlobalSetting as Mock).mockReturnValue('binariesLocation'); @@ -1399,43 +1393,6 @@ describe('binaries', () => { expect(context.telemetry.properties.FuncCoreToolsIntegrityMismatchFile).toBe('func.exe'); }); - it('returns true when a critical executable content hash matches', async () => { - const manifest = { - dependencyName: funcDependencyName, - createdAt: '2024-01-01T00:00:00.000Z', - fileCount: 1, - files: [{ path: 'func.exe', size: 100, sha256: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9' }], - }; - (fs.existsSync as Mock).mockReturnValue(true); - (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); - (fs.statSync as Mock).mockReturnValue({ size: 100, isFile: () => true }); - stubHashStream('hello world'); - - const result = await verifyDependencyIntegrity(context, funcDependencyName); - - expect(result).toBe(true); - expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('passed'); - }); - - it('returns false when a critical executable content hash no longer matches', async () => { - const manifest = { - dependencyName: funcDependencyName, - createdAt: '2024-01-01T00:00:00.000Z', - fileCount: 1, - files: [{ path: 'func.exe', size: 100, sha256: 'a'.repeat(64) }], - }; - (fs.existsSync as Mock).mockReturnValue(true); - (fs.readFileSync as Mock).mockReturnValue(JSON.stringify(manifest)); - (fs.statSync as Mock).mockReturnValue({ size: 100, isFile: () => true }); - stubHashStream('corrupted contents'); - - const result = await verifyDependencyIntegrity(context, funcDependencyName); - - expect(result).toBe(false); - expect(context.telemetry.properties.FuncCoreToolsIntegrityResult).toBe('sha256-mismatch'); - 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'); @@ -1494,22 +1451,11 @@ describe('binaries', () => { describe('writeDependencyIntegrityManifest', () => { let context: IActionContext; - const stubHashStream = () => { - (fs.createReadStream as Mock).mockImplementation((filePath: string) => { - const stream = new EventEmitter(); - process.nextTick(() => { - stream.emit('data', Buffer.from(String(filePath))); - stream.emit('end'); - }); - return stream; - }); - }; - beforeEach(() => { context = { telemetry: { properties: {} } } as IActionContext; }); - it('writes a manifest with sizes for all files and sha256 for critical executables', async () => { + 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) { @@ -1524,9 +1470,8 @@ describe('binaries', () => { (fs.statSync as Mock).mockImplementation((filePath: string) => ({ size: filePath.endsWith('func.exe') ? 100 : 200, })); - stubHashStream(); - await writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName); + writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName); expect(fs.writeFileSync).toHaveBeenCalledWith( path.join(targetFolder, '.logicapps-integrity.json'), @@ -1534,20 +1479,17 @@ describe('binaries', () => { ); const written = JSON.parse((fs.writeFileSync as Mock).mock.calls[0][1]); expect(written.fileCount).toBe(3); - // func.exe and in-proc8/func.dll are critical => hashed; README.md is size-only. 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).toMatchObject({ size: 100 }); - expect(funcExe.sha256).toMatch(/^[a-f0-9]{64}$/); - expect(funcDll).toMatchObject({ size: 200 }); - expect(funcDll.sha256).toMatch(/^[a-f0-9]{64}$/); + 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'); - expect(context.telemetry.properties.FuncCoreToolsIntegrityHashedFiles).toBe('2'); }); - it('hashes only the critical NodeJs executable, not data files', async () => { + it('records size-only entries for NodeJs files', () => { const targetFolder = path.join('binariesLocation', nodeJsDependencyName); (fs.readdirSync as Mock).mockImplementation((dir: string) => { if (dir === targetFolder) { @@ -1559,31 +1501,29 @@ describe('binaries', () => { return []; }); (fs.statSync as Mock).mockReturnValue({ size: 42 }); - stubHashStream(); - await writeDependencyIntegrityManifest(context, targetFolder, nodeJsDependencyName); + 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.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(nodeExe).toEqual({ path: 'node.exe', size: 42 }); expect(npmCmd).toEqual({ path: 'npm.cmd', size: 42 }); - expect(context.telemetry.properties.NodeJsIntegrityHashedFiles).toBe('1'); }); - it('records telemetry but does not throw when writing the manifest fails', async () => { + 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'); }); - await expect(writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName)).resolves.toBeUndefined(); + 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', async () => { + 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) { @@ -1595,9 +1535,8 @@ describe('binaries', () => { return []; }); (fs.statSync as Mock).mockReturnValue({ size: 100 }); - stubHashStream(); - await writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName); + writeDependencyIntegrityManifest(context, targetFolder, funcDependencyName); const written = JSON.parse((fs.writeFileSync as Mock).mock.calls[0][1]); expect(written.fileCount).toBe(1); diff --git a/apps/vs-code-designer/src/app/utils/binaries.ts b/apps/vs-code-designer/src/app/utils/binaries.ts index f923ee18be4..1412b5404a5 100644 --- a/apps/vs-code-designer/src/app/utils/binaries.ts +++ b/apps/vs-code-designer/src/app/utils/binaries.ts @@ -83,7 +83,7 @@ export async function downloadAndExtractDependency( 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); @@ -107,7 +107,7 @@ export async function downloadAndExtractDependency( // 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 downloadFileWithVerification only validates + // 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) { @@ -130,7 +130,7 @@ export async function downloadAndExtractDependency( } throw new Error(errorMessage); } - executeCommand(ext.outputChannel, undefined, 'echo', `Verified ${dependencyName} download checksum.`); + ext.outputChannel.appendLog(localize('verifiedDownloadChecksum', 'Verified {0} download checksum.', dependencyName)); } try { @@ -180,7 +180,7 @@ export async function downloadAndExtractDependency( // 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) { - await writeDependencyIntegrityManifest(context, targetFolder, dependencyName); + writeDependencyIntegrityManifest(context, targetFolder, dependencyName); } if (dependencyName === funcDependencyName) { // Add execute permissions for func and gozip binaries @@ -252,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, @@ -276,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.' : '' + ) ); }, }, @@ -450,7 +452,7 @@ export async function computeFileSha256(filePath: string): Promise { const hash = createHash('sha256'); const stream = fs.createReadStream(filePath); stream.on('error', reject); - stream.on('data', (chunk: Buffer) => hash.update(chunk)); + stream.on('data', (chunk: Buffer) => hash.update(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))); stream.on('end', () => resolve(hash.digest('hex'))); }); } @@ -459,13 +461,13 @@ export async function computeFileSha256(filePath: string): Promise { * 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} version - The Node.js version being downloaded (e.g. "20.19.4"). - * @param {string} artifactFileName - The artifact file name (e.g. "node-v20.19.4-win-x64.zip"). + * @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, version: string, artifactFileName: string): Promise { +export async function getNodeJsSha256(context: IActionContext, downloadUrl: string): Promise { try { - const shasumsUrl = `https://nodejs.org/dist/v${version}/SHASUMS256.txt`; + 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')) { @@ -525,11 +527,7 @@ export async function resolveExpectedDependencySha256( dependencyName: string ): Promise { if (dependencyName === nodeJsDependencyName) { - const match = downloadUrl.match(/nodejs\.org\/dist\/v([^/]+)\/(.+)$/); - if (match) { - return getNodeJsSha256(context, match[1], match[2]); - } - return undefined; + return getNodeJsSha256(context, downloadUrl); } if (dependencyName === funcDependencyName) { return getFuncCoreToolsSha256(context, downloadUrl); @@ -540,7 +538,6 @@ export async function resolveExpectedDependencySha256( interface IntegrityManifestEntry { path: string; size: number; - sha256?: string; } interface IntegrityManifest { @@ -550,32 +547,6 @@ interface IntegrityManifest { files: IntegrityManifestEntry[]; } -/** - * The primary executables per dependency whose content (not just presence/size) is SHA256-verified. - * These are the binaries that must launch at runtime (a corrupt node/func here breaks startup), so - * they get a bounded content check while the rest of the tree relies on the faster size/existence - * check. Matched by file basename (case-insensitive) so both Windows (.exe) and *nix layouts work. - */ -const criticalIntegrityFileNames: Record = { - [nodeJsDependencyName]: ['node.exe', 'node'], - [funcDependencyName]: ['func.exe', 'func', 'func.dll'], -}; - -/** - * Whether a manifest entry is a primary executable that should be SHA256 content-verified. - * @param {string} dependencyName - The dependency name (e.g. NodeJs, FuncCoreTools). - * @param {string} relativePath - The entry's relative path (posix separators). - * @returns {boolean} true when the file is in the dependency's critical-executable set. - */ -function isCriticalIntegrityFile(dependencyName: string, relativePath: string): boolean { - const criticalNames = criticalIntegrityFileNames[dependencyName]; - if (!criticalNames) { - return false; - } - const baseName = relativePath.split('/').pop()?.toLowerCase() ?? ''; - return criticalNames.some((name) => name.toLowerCase() === baseName); -} - /** * 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. @@ -604,41 +575,16 @@ function listFilesWithSizes(rootFolder: string): IntegrityManifestEntry[] { /** * Writes an on-disk integrity manifest into a freshly installed dependency folder, recording every - * file's size plus a SHA256 content hash for the primary executables. Best-effort: a failure to write - * the manifest (or to hash an individual file) is logged but never fails the install. + * 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 {Promise} Resolves once the manifest has been written (or the failure logged). + * @returns {void} */ -export async function writeDependencyIntegrityManifest( - context: IActionContext, - targetFolder: string, - dependencyName: string -): Promise { +export function writeDependencyIntegrityManifest(context: IActionContext, targetFolder: string, dependencyName: string): void { try { const files = listFilesWithSizes(targetFolder); - // Content-hash only the primary executables (bounded, so startup stays fast). Best-effort per - // file: a hashing failure leaves sha256 undefined so the file falls back to the size check. - let hashedCount = 0; - for (const file of files) { - if (isCriticalIntegrityFile(dependencyName, file.path)) { - try { - file.sha256 = await computeFileSha256(path.join(targetFolder, file.path)); - hashedCount += 1; - } catch (hashError) { - ext.outputChannel.appendLog( - localize( - 'integrityHashSkip', - 'Could not hash {0} file "{1}" for the integrity manifest: {2}', - dependencyName, - file.path, - hashError instanceof Error ? hashError.message : String(hashError) - ) - ); - } - } - } const manifest: IntegrityManifest = { dependencyName, createdAt: new Date().toISOString(), @@ -648,7 +594,6 @@ export async function writeDependencyIntegrityManifest( const manifestPath = path.join(targetFolder, dependencyIntegrityManifestFileName); fs.writeFileSync(manifestPath, JSON.stringify(manifest)); context.telemetry.properties[`${dependencyName}IntegrityManifestWritten`] = 'true'; - context.telemetry.properties[`${dependencyName}IntegrityHashedFiles`] = `${hashedCount}`; ext.outputChannel.appendLog( localize('recordedIntegrityManifest', 'Recorded {0} on-disk integrity manifest ({1} files).', dependencyName, files.length) ); @@ -668,15 +613,14 @@ export async function writeDependencyIntegrityManifest( /** * Verifies an installed dependency against its on-disk integrity manifest. Returns false (which should - * trigger a wipe + reinstall) when the manifest is missing/unreadable, any recorded file is missing or - * its size no longer matches, or a primary executable's SHA256 content hash 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. + * 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 {Promise} true when the install matches its manifest; false when it should be reinstalled. + * @returns {boolean} true when the install matches its manifest; false when it should be reinstalled. */ -export async function verifyDependencyIntegrity(context: IActionContext, dependencyName: string): Promise { +export function verifyDependencyIntegrity(context: IActionContext, dependencyName: string): boolean { ext.outputChannel.appendLog(localize('validatingIntegrity', 'Validating {0} on-disk integrity...', dependencyName)); try { const binariesLocation = getGlobalSetting(autoRuntimeDependenciesPathSettingKey); @@ -752,22 +696,6 @@ export async function verifyDependencyIntegrity(context: IActionContext, depende ); return false; } - if (file.sha256) { - const actualSha256 = await computeFileSha256(absolutePath); - if (actualSha256 !== file.sha256) { - context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'sha256-mismatch'; - context.telemetry.properties[`${dependencyName}IntegrityMismatchFile`] = file.path; - ext.outputChannel.appendLog( - localize( - 'integrityFileSha256Mismatch', - '{0} on-disk integrity check FAILED: "{1}" content hash changed. Scheduling reinstall.', - dependencyName, - file.path - ) - ); - return false; - } - } } context.telemetry.properties[`${dependencyName}IntegrityResult`] = 'passed'; diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 99ec58dee7b..521c530047c 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -277,13 +277,9 @@ export const e2eStrictDependencyValidationSettingKey = 'e2eStrictDependencyValid export const useExperimentalExtensionBundleSettingKey = 'useExperimentalExtensionBundle'; export const experimentalExtensionBundleSourceUriSettingKey = 'experimentalExtensionBundleSourceUri'; export const experimentalExtensionBundleVersionSettingKey = 'experimentalExtensionBundleVersion'; -// Max time (ms) to wait for a dependency metadata/HTTP response (e.g. latest version lookups) -// before failing fast and falling back to a bundled default version. Prevents activation from -// hanging on "Validating Runtime Dependency: NodeJS" when a version-lookup request stalls. +// Timeout (ms) for dependency version/metadata HTTP lookups so activation can't hang on a stalled request. export const dependencyMetadataRequestTimeoutMs = 30 * 1000; -// File name of the on-disk integrity manifest written into each installed runtime dependency -// folder (NodeJs, FuncCoreTools). Records every extracted file + byte size so a later validation -// pass can detect a corrupt/incomplete install (e.g. a deleted DLL) and force a wipe + reinstall. +// 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'; From 10d6e214160378dc887ea0b1c79cf3e0a8818a41 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 8 Jul 2026 18:25:49 -0700 Subject: [PATCH 6/6] refactor(vscode): remove shell echo dependency logs Replace logging-only executeCommand(..., echo, ...) calls with direct output channel appendLog calls across runtime dependency validation. Keep executeCommand only for real external processes like the DotNet installer, dotnet --version, and tar extraction. Also align FuncCoreTools binary validation with NodeJS by skipping version probes when on-disk integrity has already failed, so a corrupt func install is reinstalled without executing it first. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../binaries/validateAndInstallBinaries.ts | 3 +-- .../validateFuncCoreToolsIsLatest.test.ts | 4 ++-- .../validateFuncCoreToolsIsLatest.ts | 7 +++--- .../src/app/utils/__test__/binaries.test.ts | 22 +++++-------------- .../src/app/utils/binaries.ts | 14 ++++++------ .../src/app/utils/dotnet/dotnet.ts | 2 +- 6 files changed, 21 insertions(+), 31 deletions(-) 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 e9d1287a852..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 @@ -121,12 +121,12 @@ describe('validateFuncCoreToolsIsLatest', () => { vi.mocked(useBinariesDependencies).mockResolvedValue(true); vi.mocked(binariesExist).mockResolvedValue(true); vi.mocked(verifyDependencyIntegrity).mockReturnValue(false); - vi.mocked(getLocalFuncCoreToolsVersion).mockResolvedValue('4.0.0'); - vi.mocked(getLatestFunctionCoreToolsVersion).mockResolvedValue('4.0.0'); await validateFuncCoreToolsIsLatest('4'); expect(verifyDependencyIntegrity).toHaveBeenCalledWith(expect.anything(), 'FuncCoreTools'); + expect(getLocalFuncCoreToolsVersion).not.toHaveBeenCalled(); + expect(getLatestFunctionCoreToolsVersion).not.toHaveBeenCalled(); expect(installFuncCoreToolsBinaries).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 7a7eaee4430..1f27c71bd94 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts @@ -43,11 +43,12 @@ async function validateFuncCoreToolsIsLatestBinaries(majorVersion?: string): Pro 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 || !integrityValid || localVersion === null || isOutdated; diff --git a/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts index 7ac63b41c3c..44df7d1aaa1 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/binaries.test.ts @@ -296,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 () => { @@ -315,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 () => { @@ -339,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 () => { @@ -469,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 () => { @@ -580,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}`); }); }); diff --git a/apps/vs-code-designer/src/app/utils/binaries.ts b/apps/vs-code-designer/src/app/utils/binaries.ts index 1412b5404a5..9d901fe2013 100644 --- a/apps/vs-code-designer/src/app/utils/binaries.ts +++ b/apps/vs-code-designer/src/app/utils/binaries.ts @@ -77,7 +77,7 @@ 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); @@ -102,7 +102,7 @@ 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. @@ -238,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}`); } } @@ -787,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) { @@ -798,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; } @@ -814,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 { @@ -1378,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; } }