diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index 09a4abb9e4..f485246a6a 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -3154,12 +3154,12 @@ "No databases found on the server. Please check your connection.": "No databases found on the server. Please check your connection.", "Profiler is not supported on Microsoft Fabric SQL databases.": "Profiler is not supported on Microsoft Fabric SQL databases.", "Unable to read proxy agent options.": "Unable to read proxy agent options.", - "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension./{0} is the proxy URL": { - "message": "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension.", + "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension./{0} is the proxy URL": { + "message": "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension.", "comment": ["{0} is the proxy URL"] }, - "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1}/{0} is the proxy URL{1} is the error message": { - "message": "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1}", + "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1}/{0} is the proxy URL{1} is the error message": { + "message": "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1}", "comment": ["{0} is the proxy URL", "{1} is the error message"] }, "Backup Database - {0}/{0} is the database name": { diff --git a/extensions/mssql/src/constants/locConstants.ts b/extensions/mssql/src/constants/locConstants.ts index 26ba378070..5906ac4b7d 100644 --- a/extensions/mssql/src/constants/locConstants.ts +++ b/extensions/mssql/src/constants/locConstants.ts @@ -3077,7 +3077,7 @@ export class Proxy { public static missingProtocolWarning = (proxy: string) => l10n.t({ message: - "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension.", + "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension.", args: [proxy], comment: ["{0} is the proxy URL"], }); @@ -3085,7 +3085,7 @@ export class Proxy { public static unparseableWarning = (proxy: string, errorMessage: string) => l10n.t({ message: - "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1}", + "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1}", args: [proxy, errorMessage], comment: ["{0} is the proxy URL", "{1} is the error message"], }); diff --git a/extensions/mssql/src/http/httpClientCore.ts b/extensions/mssql/src/http/httpClientCore.ts index 9de7efa338..78303c68a6 100644 --- a/extensions/mssql/src/http/httpClientCore.ts +++ b/extensions/mssql/src/http/httpClientCore.ts @@ -12,6 +12,8 @@ import { Readable } from "stream"; import { ILogger } from "../models/interfaces"; const UnableToGetProxyAgentOptionsMessage = "Unable to read proxy agent options to get tenants."; +const HTTPS_PORT = 443; +const HTTP_PORT = 80; export interface IHttpClientMessages { missingProtocolWarning(proxy: string): string; @@ -168,12 +170,12 @@ export class HttpClientCore { : new URL(proxy).protocol; if (!scheme) { - message = `Proxy settings found, but without a protocol (e.g. http://): '${proxy}'. You may encounter connection issues while using the MSSQL extension.`; + message = `Proxy settings found, but without a protocol (e.g. http://): '${proxy}'. You may encounter connection issues while using this extension.`; localizedMessage = this.dependencies.messages?.missingProtocolWarning(proxy); } } catch (err) { const errorMessage = this.getErrorMessage(err); - message = `Proxy settings found, but encountered an error while parsing the URL: '${proxy}'. You may encounter connection issues while using the MSSQL extension. Error: ${errorMessage}`; + message = `Proxy settings found, but encountered an error while parsing the URL: '${proxy}'. You may encounter connection issues while using this extension. Error: ${errorMessage}`; localizedMessage = this.dependencies.messages?.unparseableWarning(proxy, errorMessage); } @@ -286,10 +288,11 @@ export class HttpClientCore { // Request URL will include HTTPS port 443 ('https://management.azure.com:443/tenants?api-version=2019-11-01'), so // that Axios doesn't try to reach this URL with HTTP port 80 on HTTP proxies, which result in an error. See https://github.com/axios/axios/issues/925 - const HTTPS_PORT = 443; - const HTTP_PORT = 80; const parsedRequestUrl = new URL(requestUrl); - const port = parsedRequestUrl.protocol?.startsWith("https") ? HTTPS_PORT : HTTP_PORT; + // Preserve explicitly-specified ports (e.g., https://host:8443/...), only inject default when no port was provided + const port = + parsedRequestUrl.port || + (parsedRequestUrl.protocol?.startsWith("https") ? HTTPS_PORT : HTTP_PORT); return `${parsedRequestUrl.protocol}//${parsedRequestUrl.hostname}:${port}${parsedRequestUrl.pathname}${parsedRequestUrl.search}`; } @@ -410,9 +413,14 @@ export class HttpClientCore { return { host: proxyEndpoint.hostname, - port: Number(proxyEndpoint.port), + port: proxyEndpoint.port + ? Number(proxyEndpoint.port) + : proxyEndpoint.protocol === "https:" + ? HTTPS_PORT + : HTTP_PORT, auth, - rejectUnauthorized: typeof strictSSL === "boolean", + // Default to rejecting unauthorized certs unless the user explicitly disables strict SSL. + rejectUnauthorized: strictSSL !== false, }; } diff --git a/extensions/sql-database-projects/CHANGELOG.md b/extensions/sql-database-projects/CHANGELOG.md index 7d0f105e3b..8820d18fc6 100644 --- a/extensions/sql-database-projects/CHANGELOG.md +++ b/extensions/sql-database-projects/CHANGELOG.md @@ -9,6 +9,7 @@ _The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [1.5.8] - 2026-03-18 - Adds support for Microsoft.Build.Sql 2.1.0 +- Added HTTP(S) proxy support for downloading build DLLs, enabling the extension to work in environments behind a corporate proxy ## [1.5.7] - 2026-02-27 diff --git a/extensions/sql-database-projects/VSCODE_DEVELOPMENT.md b/extensions/sql-database-projects/VSCODE_DEVELOPMENT.md deleted file mode 100644 index df1c7965c5..0000000000 --- a/extensions/sql-database-projects/VSCODE_DEVELOPMENT.md +++ /dev/null @@ -1,15 +0,0 @@ -# VS Code Extension Development - -For working on the VS Code version of the package follow these steps for local development/testing. - -1. Copy the values from [package.vscode.json](./package.vscode.json) into [package.json](./package.json) (overwriting the properties with the same name there) -2. Delete the following properties (this includes their arrays of values as well) from the `contributes/menus` property in the [package.json](./package.json) - - `objectExplorer/item/context` - - `dataExplorer/context` - - `dashboard/toolbar` -3. Compile Azure Data Studio as normal and wait for it to finish -4. Run `code /extensions/sql-database-projects` from the command line to open a new VS Code instance at the `sql-database-projects` folder -5. Run the `Launch Extension in VS Code` launch target from the `Run and Debug` view -6. This should launch an `Extension Development Host` version of VS Code that is running the extension from sources. - -If you have the compilation running as watch then once you make changes you can just reload the window to pick up the latest changes being made. diff --git a/extensions/sql-database-projects/l10n/bundle.l10n.json b/extensions/sql-database-projects/l10n/bundle.l10n.json index 3d19581bbc..d25007c915 100644 --- a/extensions/sql-database-projects/l10n/bundle.l10n.json +++ b/extensions/sql-database-projects/l10n/bundle.l10n.json @@ -296,6 +296,10 @@ "Download error": "Download error", "Download progress": "Download progress", "Downloading": "Downloading", + "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the SQL Database Projects extension.": "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the SQL Database Projects extension.", + "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the SQL Database Projects extension. Error: {1}": "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the SQL Database Projects extension. Error: {1}", + "Unable to read proxy agent options.": "Unable to read proxy agent options.", + "Unable to reach nuget.org. If you are behind a proxy or in an offline environment, you can manually place the required DLL files in the build directory: {0}": "Unable to reach nuget.org. If you are behind a proxy or in an offline environment, you can manually place the required DLL files in the build directory: {0}", "Downloading {0} nuget to get build DLLs ": "Downloading {0} nuget to get build DLLs ", "Downloading from {0} to {1}": "Downloading from {0} to {1}", "Extracting DacFx build DLLs to {0}": "Extracting DacFx build DLLs to {0}", diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index 038668cb25..8fa4a1e5c3 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -579,6 +579,7 @@ "fs-extra": "^5.0.0", "promisify-child-process": "^3.1.1", "semver": "^7.5.2", + "tunnel": "0.0.6", "vscode-jsonrpc": "^8.2.1", "vscode-languageclient": "5.2.1", "which": "^2.0.2", @@ -592,6 +593,7 @@ "@types/semver": "^7.3.1", "@types/sinon": "^9.0.4", "@types/sinon-chai": "^4.0.0", + "@types/tunnel": "0.0.1", "@types/vscode": "1.98.0", "@types/which": "^2.0.1", "@types/xml-formatter": "^1.1.0", diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index c698df1e7c..7a8baa35e4 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -883,7 +883,31 @@ export const downloading = l10n.t("Downloading"); //#endregion +//#region proxy +export const Proxy = { + missingProtocolWarning: (proxy: string) => + l10n.t( + "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the SQL Database Projects extension.", + proxy, + ), + unparseableWarning: (proxy: string, errorMessage: string) => + l10n.t( + "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the SQL Database Projects extension. Error: {1}", + proxy, + errorMessage, + ), + unableToGetProxyAgentOptions: l10n.t("Unable to read proxy agent options."), +}; +//#endregion + //#region buildHelper +export function nugetDownloadFailedHelp(buildDirPath: string): string { + return l10n.t( + "Unable to reach nuget.org. If you are behind a proxy or in an offline environment, you can manually place the required DLL files in the build directory: {0}", + buildDirPath, + ); +} + export function downloadingNuget(nuget: string) { return l10n.t("Downloading {0} nuget to get build DLLs ", nuget); } diff --git a/extensions/sql-database-projects/src/common/httpClient.ts b/extensions/sql-database-projects/src/common/httpClient.ts deleted file mode 100644 index 96c2bf0eef..0000000000 --- a/extensions/sql-database-projects/src/common/httpClient.ts +++ /dev/null @@ -1,135 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as os from "os"; -import * as fs from "fs"; -import * as vscode from "vscode"; -import axios, { AxiosRequestConfig } from "axios"; -import * as constants from "./constants"; -import type { Readable } from "stream"; -import { Buffer } from "buffer"; - -const DownloadTimeoutMs = 20000; - -/** - * Class includes method for making http request - */ -export class HttpClient { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/naming-convention - private static cache: Map = new Map(); - - /** - * Makes http GET request to the given url. If useCache is set to true, returns the result from cache if exists - * @param url url to make http GET request against - * @param useCache if true and result is already cached the cached value will be returned - * @returns result of http GET request - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public static async getRequest(url: string, useCache = false): Promise { - if (useCache) { - if (HttpClient.cache.has(url)) { - return HttpClient.cache.get(url); - } - } - - const config: AxiosRequestConfig = { - headers: { - "Content-Type": "application/json", - }, - validateStatus: () => true, // Never throw - }; - const response = await axios.get(url, config); - if (response.status !== 200) { - let errorMessage: string[] = []; - errorMessage.push(response.status.toString()); - errorMessage.push(response.statusText); - if (response.data?.error) { - errorMessage.push( - `${response.data?.error?.code} : ${response.data?.error?.message}`, - ); - } - throw new Error(errorMessage.join(os.EOL)); - } - - if (useCache) { - HttpClient.cache.set(url, response.data); - } - return response.data; - } - - /** - * Gets a file/fileContents at the given URL. - * @param downloadUrl The URL to download the file from - * @param targetPath The path to download the file to - * @param outputChannel The output channel to output status messages to - * @returns Full path to the downloaded file or the contents of the file at the given downloadUrl - */ - public async download( - downloadUrl: string, - targetPath: string, - outputChannel?: vscode.OutputChannel, - ): Promise { - const response = await axios.get(downloadUrl, { - responseType: "stream", - timeout: DownloadTimeoutMs, - validateStatus: () => true, // Never throw, we check status manually - }); - - if (response.status !== 200) { - outputChannel?.appendLine(constants.downloadError); - throw new Error(response.statusText || `HTTP ${response.status}`); - } - - const contentLength = response.headers["content-length"]; - const totalBytes = parseInt(contentLength || "0"); - const totalMegaBytes = totalBytes > 0 ? totalBytes / (1024 * 1024) : undefined; - - if (totalMegaBytes !== undefined) { - outputChannel?.appendLine( - `${constants.downloading} ${downloadUrl} (0 / ${totalMegaBytes.toFixed(2)} MB)`, - ); - } - - let receivedBytes = 0; - let printThreshold = 0.1; - - const stream: Readable = response.data; - - return new Promise((resolve, reject) => { - const writer = fs.createWriteStream(targetPath); - - stream.on("data", (chunk: Buffer) => { - receivedBytes += chunk.length; - if (totalMegaBytes) { - const receivedMegaBytes = receivedBytes / (1024 * 1024); - const percentage = receivedMegaBytes / totalMegaBytes; - if (percentage >= printThreshold) { - outputChannel?.appendLine( - `${constants.downloadProgress} (${receivedMegaBytes.toFixed(2)} / ${totalMegaBytes.toFixed(2)} MB)`, - ); - printThreshold += 0.1; - } - } - }); - - stream.on("error", (err: Error) => { - outputChannel?.appendLine(constants.downloadError); - writer.destroy(); - reject(err); - }); - - writer.on("close", () => { - resolve(); - }); - - writer.on("error", (err: Error) => { - stream.destroy(err); - reject(err); - }); - - stream.pipe(writer); - }); - } -} diff --git a/extensions/sql-database-projects/src/common/logger.ts b/extensions/sql-database-projects/src/common/logger.ts new file mode 100644 index 0000000000..c73778f634 --- /dev/null +++ b/extensions/sql-database-projects/src/common/logger.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// NOTE: This file should always be kept in sync with the equivalent in the MSSQL extension: +// extensions/mssql/src/models/logger.ts + +import * as os from "os"; +import { OutputChannel } from "vscode"; + +// Inlined from extensions/mssql/src/models/interfaces.ts - keep in sync. +export interface ILogger { + logDebug(message: string): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + verbose(msg: any, ...vals: any[]): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + warn(msg: any, ...vals: any[]): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error(msg: any, ...vals: any[]): void; + piiSanitized( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + msg: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + objsToSanitize: { name: string; objOrArray: any | any[] }[], + stringsToShorten: { name: string; value: string }[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...vals: any[] + ): void; + increaseIndent(): void; + decreaseIndent(): void; + append(message?: string): void; + appendLine(message?: string): void; +} + +/** + * Logger levels, ordered from most critical to most verbose. + * Matches the subset used by sql-database-projects build tooling. + */ +export enum LogLevel { + Error = 0, + Warning = 1, + Information = 2, + Verbose = 3, +} + +/** + * Logger that writes formatted, timestamped messages to a VS Code OutputChannel. + * Implements ILogger so it can be passed directly to HttpClient / HttpClientCore. + * + * During build operations the log level is always set to Verbose so that proxy + * diagnostic messages are visible in the "Database Projects" output channel. + */ +export class Logger implements ILogger { + private _indentLevel: number = 0; + private _indentSize: number = 4; + private _atLineStart: boolean = true; + + constructor( + private readonly _writer: (message: string) => void, + private readonly _logLevel: LogLevel = LogLevel.Verbose, + private readonly _prefix?: string, + ) {} + + /** + * Creates a Logger that appends to the given OutputChannel. + * The log level is always Verbose so all proxy/HTTP diagnostics are shown. + */ + public static create(channel: OutputChannel, prefix?: string): Logger { + return new Logger((msg) => channel.append(msg), LogLevel.Verbose, prefix); + } + + /** + * PII-sanitized logging — no-op in sql-database-projects. + * Build tooling doesn't handle user credentials or sensitive tokens. + */ + + public piiSanitized( + _msg: any, + _objsToSanitize: any[], + _stringsToShorten: any[], + ..._vals: any[] + ): void { + // intentional no-op: sqlproj build tooling does not log PII + } + + public logDebug(message: string): void { + this.write(LogLevel.Verbose, message); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public verbose(msg: any, ...vals: any[]): void { + this.write(LogLevel.Verbose, msg, ...vals); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public warn(msg: any, ...vals: any[]): void { + this.write(LogLevel.Warning, msg, ...vals); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public error(msg: any, ...vals: any[]): void { + this.write(LogLevel.Error, msg, ...vals); + } + + public increaseIndent(): void { + this._indentLevel += 1; + } + + public decreaseIndent(): void { + if (this._indentLevel > 0) { + this._indentLevel -= 1; + } + } + + public append(message?: string): void { + this.appendCore(message ?? ""); + } + + public appendLine(message?: string): void { + this.appendCore((message ?? "") + os.EOL); + this._atLineStart = true; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private write(logLevel: LogLevel, msg: any, ...vals: any[]): void { + if (logLevel <= this._logLevel) { + let fullMessage = `[${LogLevel[logLevel]}]: ${msg}`; + if (vals.length > 0) { + fullMessage += ` - ${vals.map((v) => JSON.stringify(v)).join(" - ")}`; + } + this.appendLine(fullMessage); + } + } + + private appendCore(message: string): void { + if (this._atLineStart) { + if (this._indentLevel > 0) { + this._writer(" ".repeat(this._indentLevel * this._indentSize)); + } + this._writer(`[${new Date().toLocaleTimeString()}] `); + if (this._prefix) { + this._writer(`[${this._prefix}] `); + } + this._atLineStart = false; + } + this._writer(message); + } +} diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index c7b42dfcda..7d2e9eef24 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -21,6 +21,7 @@ import * as constants from "../common/constants"; import { SqlDatabaseProjectProvider } from "../projectProvider/projectProvider"; import { GenerateProjectFromOpenApiSpecOptions, ItemType } from "sqldbproj"; import { FileNode } from "../models/tree/fileFolderTreeItem"; +import { HttpClient } from "../http/httpClient"; /** * The main controller class that initializes the extension @@ -61,6 +62,9 @@ export default class MainController implements vscode.Disposable { .update(DotnetInstallLocationKey, oldNetCoreInstallSetting, true); } + // Warn about invalid proxy settings early during activation + new HttpClient().warnOnInvalidProxySettings(); + await this.initializeDatabaseProjects(); return new SqlDatabaseProjectProvider(this.projectsController); } diff --git a/extensions/sql-database-projects/src/http/httpClient.ts b/extensions/sql-database-projects/src/http/httpClient.ts new file mode 100644 index 0000000000..087b167d1f --- /dev/null +++ b/extensions/sql-database-projects/src/http/httpClient.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from "vscode"; +import * as fs from "fs"; +import { + HttpClientCore, + IHttpClientDependencies, + HttpDownloadError, + IDownloadFileOptions, + IDownloadFileResult, +} from "./httpClientCore"; +import { ILogger } from "../common/logger"; +import * as constants from "../common/constants"; +import { getErrorMessage } from "../common/utils"; + +export class HttpClient extends HttpClientCore { + constructor(logger?: ILogger) { + const dependencies: IHttpClientDependencies = { + getProxyConfig: () => + vscode.workspace.getConfiguration("http")["proxy"] as string | undefined, + getProxyStrictSSL: () => + vscode.workspace.getConfiguration("http")["proxyStrictSSL"] as boolean | undefined, + + parseUriScheme: (value: string) => vscode.Uri.parse(value).scheme, + showWarningMessage: (message: string) => { + void vscode.window.showWarningMessage(message); + }, + getErrorMessage, + // Provide a messages implementation so that HttpClientCore can surface + // proxy warnings via showWarningMessage. Without this, warnOnInvalidProxySettings + // would silently skip the showWarningMessage call even though it is wired up above. + messages: { + missingProtocolWarning: constants.Proxy.missingProtocolWarning, + unparseableWarning: constants.Proxy.unparseableWarning, + unableToGetProxyAgentOptions: constants.Proxy.unableToGetProxyAgentOptions, + }, + }; + super(logger, dependencies); + } + + /** + * Downloads a file from downloadUrl and writes it to targetPath (path-based wrapper over downloadFile). + * Used by build tooling (buildHelper.ts) which works with file paths rather than file descriptors. + */ + public async download( + downloadUrl: string, + targetPath: string, + outputChannel?: vscode.OutputChannel, + ): Promise { + const fd = fs.openSync(targetPath, "w"); + + let totalMB: number | undefined; + let receivedBytes = 0; + let printThreshold = 0.1; + + const options: IDownloadFileOptions = { + onHeaders: (headers) => { + const totalBytes = parseInt((headers["content-length"] as string) || "0"); + totalMB = totalBytes > 0 ? totalBytes / (1024 * 1024) : undefined; + if (totalMB !== undefined) { + outputChannel?.appendLine( + `${constants.downloading} ${downloadUrl} (0 / ${totalMB.toFixed(2)} MB)`, + ); + } + }, + onData: (chunk: Buffer) => { + receivedBytes += chunk.length; + if (totalMB) { + const receivedMB = receivedBytes / (1024 * 1024); + if (receivedMB / totalMB >= printThreshold) { + outputChannel?.appendLine( + `${constants.downloadProgress} (${receivedMB.toFixed(2)} / ${totalMB.toFixed(2)} MB)`, + ); + printThreshold += 0.1; + } + } + }, + }; + + let result: IDownloadFileResult; + try { + result = await this.downloadFile(downloadUrl, fd, options); + } catch (e) { + outputChannel?.appendLine(`${constants.downloadError}: ${getErrorMessage(e)}`); + throw e; + } finally { + fs.closeSync(fd); + } + + if (result.status !== 200) { + const message = `HTTP ${result.status}`; + outputChannel?.appendLine(`${constants.downloadError}: ${message}`); + throw new Error(message); + } + } +} + +export { ILogger, HttpDownloadError, IDownloadFileOptions, IDownloadFileResult }; diff --git a/extensions/sql-database-projects/src/http/httpClientCore.ts b/extensions/sql-database-projects/src/http/httpClientCore.ts new file mode 100644 index 0000000000..f1e0520841 --- /dev/null +++ b/extensions/sql-database-projects/src/http/httpClientCore.ts @@ -0,0 +1,503 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// NOTE: This file should always be kept in sync with the equivalent in the MSSQL extension: +// extensions/mssql/src/http/httpClientCore.ts + +import * as tunnel from "tunnel"; +import * as http from "http"; +import * as https from "https"; +import * as fs from "fs"; +import axios, { AxiosRequestConfig, AxiosResponse, RawAxiosResponseHeaders } from "axios"; +import { Readable } from "stream"; +import { ILogger } from "../common/logger"; + +const UnableToGetProxyAgentOptionsMessage = "Unable to read proxy agent options."; +const HTTPS_PORT = 443; +const HTTP_PORT = 80; + +export interface IHttpClientMessages { + missingProtocolWarning(proxy: string): string; + unparseableWarning(proxy: string, errorMessage: string): string; + unableToGetProxyAgentOptions: string; +} + +export interface IHttpClientDependencies { + getProxyConfig?: () => string | undefined; + getProxyStrictSSL?: () => boolean | undefined; + parseUriScheme?: (value: string) => string | undefined; + showWarningMessage?: (message: string) => void; + getErrorMessage?: (error: unknown) => string; + messages?: IHttpClientMessages; +} + +/** + * Core HTTP client class that is independent of VS Code APIs and can be used in any context, like the build/pipeline infrastructure. + * The HttpClient class extends this core class and provides VS Code specific implementations of the dependencies. + */ +export class HttpClientCore { + constructor( + protected readonly logger?: ILogger, + private readonly dependencies: IHttpClientDependencies = {}, + ) {} + + /** + * Makes a GET request to the specified URL with the provided token. + */ + public async makeGetRequest( + requestUrl: string, + token: string, + ): Promise> { + const request = this.setupRequest(requestUrl, token); + + const response: AxiosResponse = await axios.get( + request.requestUrl, + request.config, + ); + this.logger?.piiSanitized( + "GET request ", + [ + { + name: "response", + objOrArray: + (response.data?.value as TResponse) ?? + (response.data as { value: TResponse }), + }, + ], + [], + request.requestUrl, + ); + return response; + } + + /** + * Makes a POST request to the specified URL with the provided token and payload. + */ + public async makePostRequest( + requestUrl: string, + token: string, + payload: TPayload, + ): Promise> { + const request = this.setupRequest(requestUrl, token); + + const response: AxiosResponse = await axios.post( + request.requestUrl, + payload, + request.config, + ); + this.logger?.piiSanitized( + "POST request ", + [{ name: "response", objOrArray: response.data }], + [], + request.requestUrl, + ); + return response; + } + + /** + * Downloads a file from the specified URL to the destination file descriptor, with optional callbacks for headers and data received. + * @param requestUrl request URL to download the file from + * @param destinationFd file descriptor of the destination file to write the downloaded content to + * @param options optional callbacks for headers and data received + * @returns result of the download operation, including the HTTP status and response headers + */ + public async downloadFile( + requestUrl: string, + destinationFd: number, + options?: IDownloadFileOptions, + ): Promise { + const request = this.setupRequest(requestUrl); + const requestConfig: AxiosRequestConfig = { + ...request.config, + responseType: "stream", + }; + + let response: AxiosResponse; + try { + response = await axios.get(request.requestUrl, requestConfig); + } catch (error: unknown) { + throw new HttpDownloadError("request", error as NodeJS.ErrnoException); + } + + options?.onHeaders?.(response.headers); + if (response.status !== 200) { + response.data.destroy(); + return { + status: response.status, + headers: response.headers, + }; + } + + await new Promise((resolve, reject) => { + // autoClose: false keeps fd ownership with the caller (who calls fs.closeSync in finally). + // Without it, pipe's automatic end() would trigger auto-close of the fd, + // causing the caller's fs.closeSync to throw EBADF. + const tmpFile = fs.createWriteStream("", { fd: destinationFd, autoClose: false }); + + const cleanup = (err: NodeJS.ErrnoException) => { + // Destroy both streams to avoid file-descriptor leaks and stalled pipes + response.data.destroy(); + tmpFile.destroy(); + reject(new HttpDownloadError("response", err)); + }; + + response.data.on("data", (data: Buffer) => { + options?.onData?.(data); + }); + + response.data.on("error", cleanup); + tmpFile.on("error", cleanup); + + // Resolve only after the WriteStream has fully flushed to disk. + // pipe automatically calls tmpFile.end() when the response stream finishes, + // which triggers the 'finish' event once all bytes have been written. + tmpFile.on("finish", resolve); + + response.data.pipe(tmpFile); + }); + + return { + status: response.status, + headers: response.headers, + }; + } + + public warnOnInvalidProxySettings(): void { + const proxy = this.loadProxyConfig(); + if (!proxy) { + return; + } + + let message = undefined; + let localizedMessage = undefined; + + try { + const scheme = this.dependencies.parseUriScheme + ? this.dependencies.parseUriScheme(proxy) + : new URL(proxy).protocol; + + if (!scheme) { + message = `Proxy settings found, but without a protocol (e.g. http://): '${proxy}'. You may encounter connection issues while using this extension.`; + localizedMessage = this.dependencies.messages?.missingProtocolWarning(proxy); + } + } catch (err) { + const errorMessage = this.getErrorMessage(err); + message = `Proxy settings found, but encountered an error while parsing the URL: '${proxy}'. You may encounter connection issues while using this extension. Error: ${errorMessage}`; + localizedMessage = this.dependencies.messages?.unparseableWarning(proxy, errorMessage); + } + + if (message) { + if (localizedMessage) { + this.dependencies.showWarningMessage?.(localizedMessage); + } + this.logger?.warn(message); + } + } + + /** + * Sets up the request URL and Axios request configuration, including headers and proxy/agent settings, based on the provided URL and token. + * Public for testing purposes. + */ + public setupRequest( + requestUrl: string, + token?: string, + ): { requestUrl: string; config: AxiosRequestConfig } { + const config = this.setupConfigAndProxyForRequest(requestUrl, token); + return { + requestUrl: this.constructRequestUrl(requestUrl, config), + config, + }; + } + + /** + * Builds an Axios request config with headers, auth token, and proxy/agent settings. + * + * - Adds JSON content type and bearer token headers. + * - Disables Axios status throwing (`validateStatus` always true). + * - Checks VS Code HTTP proxy settings or environment variables. + * - If a proxy is found, disables Axios' default proxy handling + * and attaches a custom HTTP/HTTPS agent. + * + * @param requestUrl - The target request URL. + * @param token - Bearer token for the Authorization header. + * @returns AxiosRequestConfig with headers and proxy/agent configuration. + */ + private setupConfigAndProxyForRequest(requestUrl: string, token?: string): AxiosRequestConfig { + const headers: { "Content-Type": string; Authorization?: string } = { + "Content-Type": "application/json", + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const config: AxiosRequestConfig = { + headers, + validateStatus: () => true, // Never throw + }; + + const proxy = this.loadProxyConfig(); + + if (proxy) { + this.logger?.verbose( + "Proxy endpoint found in environment variables or workspace configuration.", + ); + + // Turning off automatic proxy detection to avoid issues with tunneling agent by setting proxy to false. + // https://github.com/axios/axios/blob/bad6d8b97b52c0c15311c92dd596fc0bff122651/lib/adapters/http.js#L85 + config.proxy = false; + + const agent = this.createProxyAgent( + requestUrl, + proxy, + this.dependencies.getProxyStrictSSL?.(), + ); + // Axios selects the agent based on the *request* URL scheme, not the proxy scheme. + // An HTTPS request must use httpsAgent even when routed through an HTTP proxy. + if (requestUrl.startsWith("https")) { + config.httpsAgent = agent.agent; + } else { + config.httpAgent = agent.agent; + } + } + return config; + } + + /** + * Attempts to read proxy configuration in priority order: + * 1. VS Code settings (http.proxy config key) + * 2. environment variables (HTTP_PROXY, then HTTPS_PROXY) + * @returns found proxy information + */ + private loadProxyConfig(): string | undefined { + let proxy: string | undefined = this.dependencies.getProxyConfig?.(); + + if (!proxy) { + this.logger?.verbose( + "Workspace HTTP config didn't contain a proxy endpoint. Checking environment variables.", + ); + proxy = this.loadEnvironmentProxyValue(); + } + + return proxy; + } + + /** + * Constructs a request URL that explicitly includes the port number. + * + * When a proxy is configured, `setupConfigAndProxyForRequest` sets `config.proxy = false` (to + * disable Axios's built-in proxy auto-detection in favour of a tunneling agent). In that case, + * and when no proxy is involved at all (`config.proxy === undefined`), the explicit port is + * added to the URL so that Axios does not fall back to port 80 when routing through an HTTP + * proxy. The original URL is returned unchanged only when `config.proxy` is a truthy + * `AxiosProxyConfig` object, which does not occur in the current code paths. + * + * @param requestUrl - The original request URL. + * @param config - The Axios request configuration, which may contain proxy settings. + * @returns A URL string with the explicit port included, or the original URL when + * `config.proxy` is a truthy proxy config object. + */ + private constructRequestUrl(requestUrl: string, config: AxiosRequestConfig): string { + if (!config.proxy) { + // Request URL will include HTTPS port 443 ('https://management.azure.com:443/tenants?api-version=2019-11-01'), so + // that Axios doesn't try to reach this URL with HTTP port 80 on HTTP proxies, which result in an error. See https://github.com/axios/axios/issues/925 + + const parsedRequestUrl = new URL(requestUrl); + // Preserve explicitly-specified ports (e.g., https://host:8443/...), only inject default when no port was provided + const port = + parsedRequestUrl.port || + (parsedRequestUrl.protocol?.startsWith("https") ? HTTPS_PORT : HTTP_PORT); + + return `${parsedRequestUrl.protocol}//${parsedRequestUrl.hostname}:${port}${parsedRequestUrl.pathname}${parsedRequestUrl.search}`; + } + return requestUrl; + } + + private loadEnvironmentProxyValue(): string | undefined { + const HTTP_PROXY = "HTTP_PROXY"; + const HTTPS_PROXY = "HTTPS_PROXY"; + + if (!process) { + this.logger?.verbose( + "No process object found, unable to read environment variables for proxy.", + ); + return undefined; + } + + if (process.env[HTTP_PROXY] || process.env[HTTP_PROXY.toLowerCase()]) { + this.logger?.verbose("Loading proxy value from HTTP_PROXY environment variable."); + + return process.env[HTTP_PROXY] || process.env[HTTP_PROXY.toLowerCase()]; + } else if (process.env[HTTPS_PROXY] || process.env[HTTPS_PROXY.toLowerCase()]) { + this.logger?.verbose("Loading proxy value from HTTPS_PROXY environment variable."); + + return process.env[HTTPS_PROXY] || process.env[HTTPS_PROXY.toLowerCase()]; + } + + this.logger?.verbose( + "No proxy value found in either HTTPS_PROXY or HTTP_PROXY environment variables.", + ); + + return undefined; + } + + private createProxyAgent( + requestUrl: string, + proxy: string, + proxyStrictSSL?: boolean, + ): ProxyAgent { + const agentOptions = this.getProxyAgentOptions(new URL(requestUrl), proxy, proxyStrictSSL); + if (!agentOptions || !agentOptions.host || !agentOptions.port) { + this.logger?.error("Unable to read proxy agent options to create proxy agent."); + throw new Error( + this.dependencies.messages?.unableToGetProxyAgentOptions ?? + UnableToGetProxyAgentOptionsMessage, + ); + } + + let tunnelOptions: tunnel.HttpsOverHttpsOptions = {}; + if (typeof agentOptions.auth === "string" && agentOptions.auth) { + tunnelOptions = { + proxy: { + proxyAuth: agentOptions.auth, + host: agentOptions.host, + port: Number(agentOptions.port), + }, + }; + } else { + tunnelOptions = { + proxy: { + host: agentOptions.host, + port: Number(agentOptions.port), + }, + }; + } + + const isHttpsRequest = requestUrl.startsWith("https"); + const isHttpsProxy = proxy.startsWith("https"); + return { + agent: this.createTunnelingAgent(isHttpsRequest, isHttpsProxy, tunnelOptions), + }; + } + + private createTunnelingAgent( + isHttpsRequest: boolean, + isHttpsProxy: boolean, + tunnelOptions: tunnel.HttpsOverHttpsOptions, + ): http.Agent | https.Agent { + if (isHttpsRequest && isHttpsProxy) { + this.logger?.verbose("Creating https request over https proxy tunneling agent"); + return tunnel.httpsOverHttps(tunnelOptions); + } else if (isHttpsRequest && !isHttpsProxy) { + this.logger?.verbose("Creating https request over http proxy tunneling agent"); + return tunnel.httpsOverHttp(tunnelOptions); + } else if (!isHttpsRequest && isHttpsProxy) { + this.logger?.verbose("Creating http request over https proxy tunneling agent"); + return tunnel.httpOverHttps(tunnelOptions); + } else { + this.logger?.verbose("Creating http request over http proxy tunneling agent"); + return tunnel.httpOverHttp(tunnelOptions); + } + } + + /* + * Returns the proxy agent using the proxy url in the parameters or the system proxy. Returns null if no proxy found + */ + private getProxyAgentOptions( + requestURL: URL, + proxy?: string, + strictSSL?: boolean, + ): ProxyAgentOptions | undefined { + const proxyURL = proxy || this.getSystemProxyURL(requestURL); + + if (!proxyURL) { + return undefined; + } + + const proxyEndpoint = new URL(proxyURL); + if (!/^https?:$/.test(proxyEndpoint.protocol!)) { + return undefined; + } + + const auth = + proxyEndpoint.username || proxyEndpoint.password + ? `${proxyEndpoint.username}:${proxyEndpoint.password}` + : undefined; + + return { + host: proxyEndpoint.hostname, + port: proxyEndpoint.port + ? Number(proxyEndpoint.port) + : proxyEndpoint.protocol === "https:" + ? HTTPS_PORT + : HTTP_PORT, + auth, + // Default to rejecting unauthorized certs unless the user explicitly disables strict SSL. + rejectUnauthorized: strictSSL !== false, + }; + } + + private getSystemProxyURL(requestURL: URL): string | undefined { + if (requestURL.protocol === "http:") { + return process.env.HTTP_PROXY || process.env.http_proxy || undefined; + } else if (requestURL.protocol === "https:") { + return ( + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + undefined + ); + } + + return undefined; + } + + private getErrorMessage(error: unknown): string { + if (this.dependencies.getErrorMessage) { + return this.dependencies.getErrorMessage(error); + } + + if (error instanceof Error) { + return typeof error.message === "string" ? error.message : ""; + } + if (typeof error === "string") { + return error; + } + return `${JSON.stringify(error, undefined, "\t")}`; + } +} + +interface ProxyAgent { + agent: http.Agent | https.Agent; +} + +interface ProxyAgentOptions { + auth: string | undefined; + secureProxy?: boolean; + host?: string | null; + path?: string | null; + port?: string | number | null; + rejectUnauthorized: boolean; +} + +export class HttpDownloadError extends Error { + constructor( + public phase: "request" | "response", + public innerError: NodeJS.ErrnoException, + ) { + super(innerError.message); + } +} + +export interface IDownloadFileOptions { + onHeaders?: (headers: RawAxiosResponseHeaders) => void; + onData?: (data: Buffer) => void; +} + +export interface IDownloadFileResult { + status: number; + headers: RawAxiosResponseHeaders; +} diff --git a/extensions/sql-database-projects/src/tools/buildHelper.ts b/extensions/sql-database-projects/src/tools/buildHelper.ts index 4ba0ba5fe5..fde5f4293d 100644 --- a/extensions/sql-database-projects/src/tools/buildHelper.ts +++ b/extensions/sql-database-projects/src/tools/buildHelper.ts @@ -10,13 +10,33 @@ import * as utils from "../common/utils"; import * as sqldbproj from "sqldbproj"; import * as extractZip from "extract-zip"; import * as constants from "../common/constants"; -import { HttpClient } from "../common/httpClient"; +import { HttpClient } from "../http/httpClient"; import { getMicrosoftBuildSqlVersion } from "./netcoreTool"; import { ProjectType } from "../common/typeHelper"; import * as vscodeMssql from "vscode-mssql"; const buildDirectory = "BuildDirectory"; +/** + * Thrown when the nuget package download step fails (e.g. network / proxy issues). + */ +export class NugetDownloadError extends Error { + constructor(message: string) { + super(message); + this.name = "NugetDownloadError"; + } +} + +/** + * Thrown when the nuget package extraction (or post-download filesystem) step fails. + */ +export class NugetExtractionError extends Error { + constructor(message: string) { + super(message); + this.name = "NugetExtractionError"; + } +} + export class BuildHelper { private extensionDir: string; private extensionBuildDir: string; @@ -112,28 +132,23 @@ export class BuildHelper { nugetFolderWithExpectedfiles: string, outputChannel: vscode.OutputChannel, ): Promise { - let missingNuget = false; - const fullNugetName = `${nugetName}.${nugetVersion}`; const fullNugetPath = path.join(this.extensionBuildDir, `${fullNugetName}.nupkg`); - // check if the correct nuget version has been previously downloaded before checking if the files exist. - // TODO: handle when multiple nugets are in the BuildDirectory and a user wants to switch back to an older one - probably should - // remove other versions of this nuget when a new one is downloaded - if (await utils.exists(fullNugetPath)) { - // if it does exist, make sure all the necessary files are also in the BuildDirectory - for (const fileName of expectedFiles) { - if (!(await utils.exists(path.join(this.extensionBuildDir, fileName)))) { - missingNuget = true; - break; - } + // Check whether all required files are already present in the BuildDirectory. + // This covers both pre-bundled DLLs (shipped in the VSIX) and previously downloaded/extracted files. + // We do not require the .nupkg sentinel to be present — the DLLs themselves are the source of truth. + // TODO: handle when multiple versions of this nuget are in the BuildDirectory and a user wants to + // switch back to an older one - probably should remove other versions when a new one is downloaded. + let missingFiles = false; + for (const fileName of expectedFiles) { + if (!(await utils.exists(path.join(this.extensionBuildDir, fileName)))) { + missingFiles = true; + break; } - } else { - // if the nuget isn't there, it needs to be downloaded and the build dlls extracted - missingNuget = true; } - if (!missingNuget) { + if (!missingFiles) { return true; } @@ -150,7 +165,17 @@ export class BuildHelper { outputChannel, ); } catch (e) { - void vscode.window.showErrorMessage(e); + const errorMessage = utils.getErrorMessage(e); + if (e instanceof NugetDownloadError) { + // Network / connectivity failure — show the proxy/offline help text so users can resolve configuration issues. + const helpMessage = constants.nugetDownloadFailedHelp(this.extensionBuildDir); + outputChannel.appendLine(`${errorMessage}\n${helpMessage}`); + void vscode.window.showErrorMessage(helpMessage); + } else { + // Extraction or filesystem failure — the error itself is actionable; + outputChannel.appendLine(errorMessage); + void vscode.window.showErrorMessage(errorMessage); + } return false; } @@ -190,13 +215,17 @@ export class BuildHelper { outputChannel.appendLine(constants.downloadingFromTo(downloadUrl, nugetPath)); await httpClient.download(downloadUrl, nugetPath, outputChannel); } catch (e) { - throw constants.errorDownloading(extractFolderPath, utils.getErrorMessage(e)); + throw new NugetDownloadError( + constants.errorDownloading(downloadUrl, utils.getErrorMessage(e)), + ); } try { await extractZip(nugetPath, { dir: extractFolderPath }); } catch (e) { - throw constants.errorExtracting(nugetPath, utils.getErrorMessage(e)); + throw new NugetExtractionError( + constants.errorExtracting(nugetPath, utils.getErrorMessage(e)), + ); } } diff --git a/extensions/sql-database-projects/test/buildHelper.test.ts b/extensions/sql-database-projects/test/buildHelper.test.ts index bc0116d4e6..da62f0be17 100644 --- a/extensions/sql-database-projects/test/buildHelper.test.ts +++ b/extensions/sql-database-projects/test/buildHelper.test.ts @@ -6,9 +6,11 @@ import { expect } from "chai"; import * as os from "os"; import * as fs from "fs"; +import * as sinon from "sinon"; import * as vscode from "vscode"; import * as path from "path"; -import { BuildHelper } from "../src/tools/buildHelper"; +import { BuildHelper, NugetExtractionError } from "../src/tools/buildHelper"; +import { HttpClient } from "../src/http/httpClient"; import { TestContext, createContext } from "./testContext"; import { ProjectType } from "vscode-mssql"; import * as sqldbproj from "sqldbproj"; @@ -16,6 +18,16 @@ import * as constants from "../src/common/constants"; import * as utils from "../src/common/utils"; suite("BuildHelper: Build Helper tests", function (): void { + let sandbox: sinon.SinonSandbox; + + setup(function () { + sandbox = sinon.createSandbox(); + }); + + teardown(function () { + sandbox.restore(); + }); + test("Should get correct build arguments for legacy-style projects", function (): void { // update settings and validate const buildHelper = new BuildHelper(); @@ -155,4 +167,123 @@ suite("BuildHelper: Build Helper tests", function (): void { ).to.be.true; } }); + + test("Shows nugetDownloadFailedHelp message when nuget download throws", async function (): Promise { + // Treat all files as missing so the download path is triggered. + sandbox.stub(utils, "exists").resolves(false); + + // Make the actual HTTP download fail (simulates offline / proxy failure). + sandbox.stub(HttpClient.prototype, "download").rejects(new Error("ECONNREFUSED")); + + // Capture the error message shown to the user. + let shownMessage: string | undefined; + sandbox + .stub(vscode.window, "showErrorMessage") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .callsFake((msg: string): any => { + shownMessage = msg; + return Promise.resolve(undefined); + }); + + const outputChannel = { + appendLine: () => {}, + } as unknown as vscode.OutputChannel; + + const buildHelper = new BuildHelper(); + const buildDir = buildHelper.extensionBuildDirPath; + + const result = await buildHelper.ensureNugetAndFilesPresence( + "Microsoft.Build.Sql", + "0.1.0", + ["Microsoft.Build.Sql.dll"], + "tools/net8.0", + outputChannel, + ); + + expect(result, "ensureNugetAndFilesPresence should return false on download failure").to.be + .false; + expect(shownMessage, "showErrorMessage should have been called").to.be.a("string"); + expect(shownMessage).to.include( + buildDir, + "Error message should contain the build directory path so the user knows where to place DLLs", + ); + expect(shownMessage).to.equal( + constants.nugetDownloadFailedHelp(buildDir), + "Error message should match nugetDownloadFailedHelp constant exactly", + ); + }); + + test("Shows extraction error (not proxy/nuget help) when extraction fails", async function (): Promise { + // Treat all files as missing so the download path is triggered. + sandbox.stub(utils, "exists").resolves(false); + + // Simulate an extraction failure (e.g. corrupt zip, disk error) by stubbing + // downloadAndExtractNuget to throw a NugetExtractionError directly. This + // tests the catch-block behaviour in ensureNugetAndFilesPresence without + // relying on the internals of extract-zip. + const extractionErrorMsg = "Error extracting files from /tmp/pkg.nupkg. Error: bad zip"; + sandbox + .stub(BuildHelper.prototype, "downloadAndExtractNuget") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .rejects(new NugetExtractionError(extractionErrorMsg) as any); + + let shownMessage: string | undefined; + sandbox + .stub(vscode.window, "showErrorMessage") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .callsFake((msg: string): any => { + shownMessage = msg; + return Promise.resolve(undefined); + }); + + const outputChannel = { + appendLine: () => {}, + } as unknown as vscode.OutputChannel; + + const buildHelper = new BuildHelper(); + const buildDir = buildHelper.extensionBuildDirPath; + + const result = await buildHelper.ensureNugetAndFilesPresence( + "Microsoft.Build.Sql", + "0.1.0", + ["Microsoft.Build.Sql.dll"], + "tools/net8.0", + outputChannel, + ); + + expect(result, "ensureNugetAndFilesPresence should return false on extraction failure").to + .be.false; + expect(shownMessage, "showErrorMessage should have been called").to.be.a("string"); + expect(shownMessage).to.equal( + extractionErrorMsg, + "Extraction error should be shown directly so the user sees the real cause", + ); + expect(shownMessage).to.not.equal( + constants.nugetDownloadFailedHelp(buildDir), + "nugetDownloadFailedHelp (proxy/offline advice) must NOT be shown for extraction failures", + ); + }); + + test("Returns true without downloading when all expected files already exist", async function (): Promise { + // All files already present → download should never be called. + sandbox.stub(utils, "exists").resolves(true); + const downloadSpy = sandbox.stub(HttpClient.prototype, "download"); + + const outputChannel = { + appendLine: () => {}, + } as unknown as vscode.OutputChannel; + + const buildHelper = new BuildHelper(); + const result = await buildHelper.ensureNugetAndFilesPresence( + "Microsoft.Build.Sql", + "0.1.0", + ["Microsoft.Build.Sql.dll"], + "tools/net8.0", + outputChannel, + ); + + expect(result).to.be.true; + expect(downloadSpy.called, "download should NOT be called when files already exist").to.be + .false; + }); }); diff --git a/extensions/sql-database-projects/test/httpClient.test.ts b/extensions/sql-database-projects/test/httpClient.test.ts new file mode 100644 index 0000000000..482b1fff29 --- /dev/null +++ b/extensions/sql-database-projects/test/httpClient.test.ts @@ -0,0 +1,531 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// NOTE: This file should always be kept in sync with the equivalent in the MSSQL extension: +// extensions/mssql/test/unit/httpClient.test.ts + +import * as chai from "chai"; +import { expect } from "chai"; +import sinonChai from "sinon-chai"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; +import * as fs from "fs"; +import { PassThrough } from "stream"; +import axios, { AxiosResponse } from "axios"; +import { HttpClient, HttpDownloadError } from "../src/http/httpClient"; +import { Logger } from "../src/common/logger"; + +chai.use(sinonChai); + +suite("HttpClient tests", () => { + let sandbox: sinon.SinonSandbox; + let httpClient: HttpClient; + let logger: sinon.SinonStubbedInstance; + + setup(() => { + sandbox = sinon.createSandbox(); + + logger = sandbox.createStubInstance(Logger); + httpClient = new HttpClient(logger); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite("makeGetRequest tests", () => { + test("should make a successful GET request", async () => { + const requestUrl = "https://api.example.com/data"; + const token = "test-token"; + const responseData = { value: [{ id: 1, name: "test" }] }; + + const mockResponse: AxiosResponse = { + data: responseData, + status: 200, + statusText: "OK", + headers: {}, + config: {} as AxiosResponse["config"], + }; + + const axiosGetStub = sandbox.stub(axios, "get").resolves(mockResponse); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({ + headers: { Authorization: `Bearer ${token}` }, + validateStatus: () => true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); + + const result = await httpClient.makeGetRequest(requestUrl, token); + + expect(result).to.deep.equal(mockResponse); + expect(axiosGetStub).to.have.been.calledOnce; + }); + + test("should log GET request response", async () => { + const requestUrl = "https://api.example.com/data"; + const token = "test-token"; + const responseData = { value: [{ id: 1 }] }; + + const mockResponse: AxiosResponse = { + data: responseData, + status: 200, + statusText: "OK", + headers: {}, + config: {} as AxiosResponse["config"], + }; + + sandbox.stub(axios, "get").resolves(mockResponse); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); + + await httpClient.makeGetRequest(requestUrl, token); + + expect(logger.piiSanitized).to.have.been.calledWith( + "GET request ", + sinon.match.array, + [], + requestUrl, + ); + }); + }); + + suite("makePostRequest tests", () => { + test("should make a successful POST request", async () => { + const requestUrl = "https://api.example.com/data"; + const token = "test-token"; + const payload = { name: "new item" }; + const responseData = { id: 2, name: "new item" }; + + const mockResponse: AxiosResponse = { + data: responseData, + status: 201, + statusText: "Created", + headers: {}, + config: {} as AxiosResponse["config"], + }; + + const axiosPostStub = sandbox.stub(axios, "post").resolves(mockResponse); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({ + headers: { Authorization: `Bearer ${token}` }, + validateStatus: () => true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); + + const result = await httpClient.makePostRequest(requestUrl, token, payload); + + expect(result).to.deep.equal(mockResponse); + expect(axiosPostStub).to.have.been.calledWith(requestUrl, payload, sinon.match.any); + }); + + test("should log POST request response", async () => { + const requestUrl = "https://api.example.com/data"; + const token = "test-token"; + const payload = { name: "test" }; + const responseData = { id: 1 }; + + const mockResponse: AxiosResponse = { + data: responseData, + status: 201, + statusText: "Created", + headers: {}, + config: {} as AxiosResponse["config"], + }; + + sandbox.stub(axios, "post").resolves(mockResponse); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); + + await httpClient.makePostRequest(requestUrl, token, payload); + + expect(logger.piiSanitized).to.have.been.calledWith( + "POST request ", + sinon.match.array, + [], + requestUrl, + ); + }); + }); + + suite("downloadFile tests", () => { + test("should download successfully and invoke callbacks", async () => { + const requestUrl = "https://download.example.com/file"; + const normalizedUrl = "https://download.example.com:443/file"; + const headers = { "content-length": "5" }; + + const responseStream = new PassThrough(); + const tmpFileStream = new PassThrough(); + + sandbox + .stub(httpClient, "setupRequest") + .returns({ requestUrl: normalizedUrl, config: {} }); + + sandbox + .stub(fs, "createWriteStream") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .returns(tmpFileStream as any); + + const mockResponse: AxiosResponse = { + data: responseStream, + status: 200, + statusText: "OK", + headers, + config: {} as AxiosResponse["config"], + }; + sandbox.stub(axios, "get").resolves(mockResponse); + + const onHeaders = sandbox.spy(); + const onData = sandbox.spy(); + + const downloadPromise = httpClient.downloadFile(requestUrl, 123, { + onHeaders, + onData, + }); + + responseStream.write(Buffer.from([1, 2, 3])); + responseStream.end(Buffer.from([4, 5])); + + const result = await downloadPromise; + + expect(result.status).to.equal(200); + expect(result.headers).to.equal(headers); + expect(onHeaders).to.have.been.calledOnceWithExactly(headers); + expect(onData).to.have.callCount(2); + expect((onData.firstCall.args[0] as Buffer).length).to.equal(3); + expect((onData.secondCall.args[0] as Buffer).length).to.equal(2); + expect(axios.get).to.have.been.calledWith( + normalizedUrl, + sinon.match({ responseType: "stream" }), + ); + }); + + test("should return error code and destroy stream upon HTTP error", async () => { + const requestUrl = "https://download.example.com/file"; + const normalizedUrl = "https://download.example.com:443/file"; + const headers = { "content-length": "0" }; + + const responseStream = new PassThrough(); + const destroySpy = sandbox.spy(responseStream, "destroy"); + + sandbox + .stub(httpClient, "setupRequest") + .returns({ requestUrl: normalizedUrl, config: {} }); + + const mockResponse: AxiosResponse = { + data: responseStream, + status: 404, + statusText: "Not Found", + headers, + config: {} as AxiosResponse["config"], + }; + sandbox.stub(axios, "get").resolves(mockResponse); + + const onHeaders = sandbox.spy(); + const result = await httpClient.downloadFile(requestUrl, 123, { onHeaders }); + + expect(result.status).to.equal(404); + expect(result.headers).to.equal(headers); + expect(onHeaders).to.have.been.calledOnceWithExactly(headers); + expect(destroySpy).to.have.been.calledOnce; + }); + + test("should wrap request errors in HttpDownloadError", async () => { + const requestUrl = "https://download.example.com/file"; + + sandbox.stub(httpClient, "setupRequest").returns({ requestUrl, config: {} }); + + const requestError = new Error("network error") as NodeJS.ErrnoException; + requestError.code = "ECONNRESET"; + sandbox.stub(axios, "get").rejects(requestError); + + try { + await httpClient.downloadFile(requestUrl, 123); + expect.fail("Expected downloadFile to throw"); + } catch (error) { + expect(error).to.be.instanceOf(HttpDownloadError); + expect((error as HttpDownloadError).phase).to.equal("request"); + expect((error as HttpDownloadError).innerError).to.equal(requestError); + } + }); + + test("should wrap response stream errors in HttpDownloadError", async () => { + const requestUrl = "https://download.example.com/file"; + const responseStream = new PassThrough(); + const tmpFileStream = new PassThrough(); + + sandbox.stub(httpClient, "setupRequest").returns({ requestUrl, config: {} }); + sandbox + .stub(fs, "createWriteStream") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .returns(tmpFileStream as any); + + const mockResponse: AxiosResponse = { + data: responseStream, + status: 200, + statusText: "OK", + headers: {}, + config: {} as AxiosResponse["config"], + }; + sandbox.stub(axios, "get").resolves(mockResponse); + + const responseError = new Error("stream failed") as NodeJS.ErrnoException; + responseError.code = "EPIPE"; + + const downloadPromise = httpClient.downloadFile(requestUrl, 123); + await new Promise((resolve) => setImmediate(resolve)); + responseStream.emit("error", responseError); + + try { + await downloadPromise; + expect.fail("Expected downloadFile to throw"); + } catch (error) { + expect(error).to.be.instanceOf(HttpDownloadError); + expect((error as HttpDownloadError).phase).to.equal("response"); + expect((error as HttpDownloadError).innerError).to.equal(responseError); + } + }); + }); + + suite("Proxy validation tests", () => { + const envProxy = "env-proxy"; + const configProxy = "config-proxy"; + + test("warns when proxy lacks protocol", () => { + const invalidProxyValue = "localhost:1234"; + + httpClient["loadProxyConfig"] = sandbox.stub().returns(invalidProxyValue); + + // Use URL constructor path: new URL("localhost:1234").protocol returns "localhost:" + // which is truthy, so it won't hit the missingProtocol branch unless we stub parseUriScheme. + // Stub the private parseUriScheme dependency to return undefined (simulating no scheme). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (httpClient as any).dependencies = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(httpClient as any).dependencies, + parseUriScheme: (_proxy: string) => undefined, + }; + + const warningMessageStub = sandbox + .stub(vscode.window, "showWarningMessage") + .resolves(undefined); + + httpClient.warnOnInvalidProxySettings(); + + // messages is now wired up, so showWarningMessage IS called. + expect(warningMessageStub).to.have.been.calledOnce; + expect(warningMessageStub.firstCall.args[0]).to.include(invalidProxyValue); + expect(logger.warn).to.have.been.calledOnce; + }); + + test("warns when proxy parsing throws", () => { + const invalidProxyValue = "env-proxy.example"; + + httpClient["loadProxyConfig"] = sandbox.stub().returns(invalidProxyValue); + + // Force warnOnInvalidProxySettings to take the catch path by making URL constructor throw + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (httpClient as any).dependencies = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(httpClient as any).dependencies, + parseUriScheme: (_proxy: string) => { + throw new Error("invalid uri format"); + }, + }; + + const warningMessageStub = sandbox + .stub(vscode.window, "showWarningMessage") + .resolves(undefined); + + httpClient.warnOnInvalidProxySettings(); + + // messages is now wired up, so showWarningMessage IS called. + expect(warningMessageStub).to.have.been.calledOnce; + expect(warningMessageStub.firstCall.args[0]).to.include(invalidProxyValue); + expect(logger.warn).to.have.been.calledOnce; + }); + + test("Does not warn when proxy is valid", () => { + const validProxyValues = [ + "http://valid-proxy.test:8080", + "https://valid-proxy.example", + "socks5://valid-proxy.subdomain.domain.com:1080", + ]; + + const proxyConfigStub = sandbox.stub(); + const warningMessageSpy = sandbox.stub(vscode.window, "showWarningMessage"); + + for (const validProxyValue of validProxyValues) { + proxyConfigStub.reset(); + httpClient["loadProxyConfig"] = proxyConfigStub.returns(validProxyValue); + + httpClient.warnOnInvalidProxySettings(); + + expect(warningMessageSpy, `Should not warn for valid proxy: ${validProxyValue}`).to + .not.have.been.called; + } + }); + + test("Does not warn when proxy is undefined", () => { + httpClient["loadProxyConfig"] = sandbox.stub().returns(undefined); + + const warningMessageSpy = sandbox.stub(vscode.window, "showWarningMessage"); + + httpClient.warnOnInvalidProxySettings(); + + expect(warningMessageSpy).to.not.have.been.called; + }); + + test("loadProxyConfig prefers VS Code configuration over environment variables", () => { + sandbox + .stub(vscode.workspace, "getConfiguration") + .withArgs("http") + .returns({ proxy: configProxy } as unknown as vscode.WorkspaceConfiguration); + + sandbox.stub(process, "env").value({ + HTTP_PROXY: envProxy, + https_proxy: envProxy, + }); + + const proxy = httpClient["loadProxyConfig"](); + + expect(proxy).to.equal(configProxy); + }); + + test("loadProxyConfig falls back to environment variables when config missing", () => { + sandbox + .stub(vscode.workspace, "getConfiguration") + .withArgs("http") + .returns({ proxy: undefined } as unknown as vscode.WorkspaceConfiguration); + + sandbox.stub(process, "env").value({ + HTTP_PROXY: envProxy, + }); + + const proxy = httpClient["loadProxyConfig"](); + + expect(proxy).to.equal(envProxy); + }); + + test("setupConfigAndProxyForRequest", () => { + const fakeToken = "fake-token"; + const fakeProxyUrl = new URL("http://fake-proxy.test:8080"); + + const loadProxyConfigStub = sandbox.stub(); + httpClient["loadProxyConfig"] = loadProxyConfigStub.returns(fakeProxyUrl.toString()); + + const result = httpClient["setupConfigAndProxyForRequest"]( + "http://fakeUrl.ms/", + fakeToken, + ); + + expect(result.headers.Authorization).to.contain(fakeToken); + expect(result.proxy, "Automatic proxy detection should be disabled").to.be.false; + expect(result.httpAgent.proxyOptions).to.deep.equal({ + host: fakeProxyUrl.hostname, + port: parseInt(fakeProxyUrl.port), + }); + expect(result.httpsAgent).to.be.undefined; + }); + }); + + suite("setupConfigAndProxyForRequest tests", () => { + test("should setup config without proxy", () => { + const requestUrl = "https://api.example.com"; + const token = "test-token"; + + httpClient["loadProxyConfig"] = sandbox.stub().returns(undefined); + + const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); + + expect(result.headers).to.deep.equal({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }); + expect(result.validateStatus!(200)).to.be.true; + expect(result.proxy).to.be.undefined; + expect(result.httpAgent).to.be.undefined; + expect(result.httpsAgent).to.be.undefined; + }); + + test("should setup config with HTTPS proxy for HTTPS request", () => { + const requestUrl = "https://api.example.com"; + const token = "test-token"; + const proxy = "https://proxy.example.com:8080"; + + httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); + sandbox + .stub(vscode.workspace, "getConfiguration") + .withArgs("http") + .returns({ proxyStrictSSL: true } as unknown as vscode.WorkspaceConfiguration); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "createProxyAgent").returns({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agent: {} as any, + }); + + const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); + + expect(result.proxy).to.be.false; + expect(result.httpsAgent).to.exist; + expect(result.httpAgent).to.be.undefined; + }); + + test("should setup config with HTTP proxy for HTTPS request", () => { + const requestUrl = "https://api.example.com"; + const token = "test-token"; + const proxy = "http://proxy.example.com:8080"; + + httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); + sandbox + .stub(vscode.workspace, "getConfiguration") + .withArgs("http") + .returns({ proxyStrictSSL: false } as unknown as vscode.WorkspaceConfiguration); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "createProxyAgent").returns({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agent: {} as any, + }); + + const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); + + expect(result.proxy).to.be.false; + // HTTPS request URL → httpsAgent, regardless of proxy scheme + expect(result.httpsAgent).to.exist; + expect(result.httpAgent).to.be.undefined; + }); + + test("should log when proxy is found", () => { + const requestUrl = "https://api.example.com"; + const token = "test-token"; + const proxy = "http://proxy.example.com:8080"; + + httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); + sandbox + .stub(vscode.workspace, "getConfiguration") + .withArgs("http") + .returns({ proxyStrictSSL: false } as unknown as vscode.WorkspaceConfiguration); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(httpClient as any, "createProxyAgent").returns({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agent: {} as any, + }); + + httpClient["setupConfigAndProxyForRequest"](requestUrl, token); + + expect(logger.verbose).to.have.been.calledWith( + "Proxy endpoint found in environment variables or workspace configuration.", + ); + }); + }); +}); diff --git a/extensions/sql-database-projects/yarn.lock b/extensions/sql-database-projects/yarn.lock index c74deb57e5..5df1804568 100644 --- a/extensions/sql-database-projects/yarn.lock +++ b/extensions/sql-database-projects/yarn.lock @@ -778,6 +778,13 @@ resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== +"@types/tunnel@0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@types/tunnel/-/tunnel-0.0.1.tgz#0d72774768b73df26f25df9184273a42da72b19c" + integrity sha512-AOqu6bQu5MSWwYvehMXLukFHnupHrpZ8nvgae5Ggie9UwzDR1CCwoXgSSWNZJuyOlCdfdsWMA5F2LlmvyoTv8A== + dependencies: + "@types/node" "*" + "@types/vscode@1.98.0": version "1.98.0" resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.98.0.tgz#5b6fa5bd99ba15313567d3847fb1177832fee08c" diff --git a/localization/xliff/sql-database-projects.xlf b/localization/xliff/sql-database-projects.xlf index d46093b8de..c6a6e7d450 100644 --- a/localization/xliff/sql-database-projects.xlf +++ b/localization/xliff/sql-database-projects.xlf @@ -568,6 +568,12 @@ Project was successfully updated. + + Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the SQL Database Projects extension. Error: {1} + + + Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the SQL Database Projects extension. + Publish @@ -835,6 +841,12 @@ Unable to locate '{0}' target: '{1}'. {2} + + Unable to reach nuget.org. If you are behind a proxy or in an offline environment, you can manually place the required DLL files in the build directory: {0} + + + Unable to read proxy agent options. + Unexpected number of {0} files: {1} diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index dcf8714fe8..61e9f47340 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -4611,13 +4611,13 @@ Provisioning - - Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1} + + Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1} {0} is the proxy URL {1} is the error message - - Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension. + + Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension. {0} is the proxy URL