diff --git a/client/src/components/LibraryNavigator/LibraryModel.ts b/client/src/components/LibraryNavigator/LibraryModel.ts index 08e802885..3dd94125e 100644 --- a/client/src/components/LibraryNavigator/LibraryModel.ts +++ b/client/src/components/LibraryNavigator/LibraryModel.ts @@ -13,11 +13,28 @@ import { LibraryItemType, TableData, TableQuery, - TableRow, } from "./types"; const sortById = (a: LibraryItem, b: LibraryItem) => a.id.localeCompare(b.id); +export function stringArrayToCsvString( + strings: (string | number | null)[], +): string { + return `"${strings + .map((item) => (item ?? "").toString().replace(/"/g, '""')) + .join('","')}"`; +} + +export async function writeWithBackpressure( + stream: Writable, + data: string, +): Promise { + const canContinue = stream.write(data); + if (!canContinue) { + await new Promise((resolve) => stream.once("drain", resolve)); + } +} + class LibraryModel { public constructor(protected libraryAdapter: LibraryAdapter | undefined) {} @@ -63,12 +80,6 @@ class LibraryModel { const { rowCount: totalItemCount, maxNumberOfRowsToRead: limit } = await this.libraryAdapter.getTableRowCount(item); let hasWrittenHeader: boolean = false; - const stringArrayToCsvString = (strings: string[]): string => - `"${strings - .map((item: string | number) => - (item ?? "").toString().replace(/"/g, '""'), - ) - .join('","')}"`; await window.withProgress( { @@ -92,13 +103,20 @@ class LibraryModel { const headers = data.rows.shift(); if (!hasWrittenHeader) { - fileStream.write(stringArrayToCsvString(headers.columns)); + await writeWithBackpressure( + fileStream, + stringArrayToCsvString(headers.columns), + ); hasWrittenHeader = true; } - data.rows.forEach((item: TableRow) => - fileStream.write("\n" + stringArrayToCsvString(item.cells)), - ); + // handle backpressure: wait for drain event when buffer is full + for (const row of data.rows) { + await writeWithBackpressure( + fileStream, + "\n" + stringArrayToCsvString(row.cells), + ); + } offset += limit; } while (offset < totalItemCount); diff --git a/client/src/components/LibraryNavigator/browserDownload.ts b/client/src/components/LibraryNavigator/browserDownload.ts new file mode 100644 index 000000000..f414bbe8d --- /dev/null +++ b/client/src/components/LibraryNavigator/browserDownload.ts @@ -0,0 +1,225 @@ +// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Uri, env, l10n } from "vscode"; + +import { randomBytes, timingSafeEqual } from "crypto"; +import { createServer } from "http"; + +import LibraryDataProvider from "./LibraryDataProvider"; +import { LibraryItem } from "./types"; + +const DOWNLOAD_TOKEN_BYTES = 24; // 192-bit token +const MAX_DOWNLOAD_FILENAME_LENGTH = 250; +const BROWSER_CONNECTION_TIMEOUT_MS = 60_000; +const DOWNLOAD_ENDPOINT_PATH = "/sas-library-download"; +const DOWNLOAD_TOKEN_PARAM = "token"; +const DEFAULT_DOWNLOAD_FILENAME = "table.csv"; + +export function isValidDownloadToken( + candidate: string | null, + token: string, +): boolean { + if (!candidate || candidate.length !== token.length) { + return false; + } + return timingSafeEqual(Buffer.from(candidate), Buffer.from(token)); +} + +export function sanitizeDownloadFilename( + fileName: string, + maxLength: number, + fallback: string, +): string { + // Preserve Unicode while removing only dangerous characters + return ( + fileName + .trim() + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f"\r\n]/g, "") // Remove control chars + quotes + .replace(/\.\.+/g, ".") // Collapse multiple dots (path traversal) + .replace(/^\.+/, "") // Remove leading dots + .slice(0, maxLength) || fallback + ); +} + +export type DownloadRequestOutcome = + | { readonly outcome: 405 } + | { readonly outcome: 410 } + | { readonly outcome: 404 } + | { readonly outcome: "stream" }; + +export function classifyDownloadRequest( + method: string | undefined, + url: string | undefined, + token: string, + endpointPath: string, + tokenParam: string, + requestLock: boolean, +): DownloadRequestOutcome { + if (method !== "GET") { + return { outcome: 405 }; + } + if (requestLock) { + return { outcome: 410 }; + } + const requestUrl = url ? new URL(url, "http://127.0.0.1") : undefined; + if ( + !requestUrl || + requestUrl.pathname !== endpointPath || + !isValidDownloadToken(requestUrl.searchParams.get(tokenParam), token) + ) { + return { outcome: 404 }; + } + return { outcome: "stream" }; +} + +export async function streamTableToBrowserDownload( + item: LibraryItem, + fileName: string, + libraryDataProvider: LibraryDataProvider, +): Promise { + const token = randomBytes(DOWNLOAD_TOKEN_BYTES).toString("hex"); + const asciiFileName = sanitizeDownloadFilename( + fileName, + MAX_DOWNLOAD_FILENAME_LENGTH, + DEFAULT_DOWNLOAD_FILENAME, + ); + const encodedFileName = encodeURIComponent(asciiFileName); + + await new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout | undefined; + let requestLock = false; + let settled = false; + + const settleResolve = () => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + server.removeListener("error", errorHandler); + resolve(); + }; + + const settleReject = (error: Error) => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + server.removeListener("error", errorHandler); + server.close(() => {}); + reject(error); + }; + + const server = createServer((request, response) => { + const verdict = classifyDownloadRequest( + request.method, + request.url, + token, + DOWNLOAD_ENDPOINT_PATH, + DOWNLOAD_TOKEN_PARAM, + requestLock, + ); + + if (verdict.outcome !== "stream") { + response.statusCode = verdict.outcome; + if (verdict.outcome === 405) { + response.setHeader("Allow", "GET"); + } + response.end(); + return; + } + + requestLock = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + + response.setHeader("Content-Type", "text/csv; charset=utf-8"); + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Pragma", "no-cache"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader( + "Content-Disposition", + `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodedFileName}`, + ); + + libraryDataProvider + .writeTableContentsToStream(response, item) + .then(() => { + if (!response.writableEnded) { + response.end(); + } + settleResolve(); + }) + .catch((error) => { + if (!response.headersSent) { + response.statusCode = 500; + response.setHeader("Content-Type", "text/plain"); + response.end("Download failed"); + } else { + // Headers already sent - destroy connection to signal error to browser + response.destroy(); + } + settleReject(error); + }); + }); + + const errorHandler = (error: Error) => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + settleReject(error); + }; + + server.on("error", errorHandler); + + server.listen(0, "127.0.0.1", async () => { + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error(l10n.t("Unable to start download server.")); + } + + // asExternalUri only transforms scheme+host+port — the proxy strips + // path and query. Resolve just the base, then append path+token. + const baseLocalUri = Uri.parse(`http://127.0.0.1:${address.port}`); + const externalBase = await env.asExternalUri(baseLocalUri); + const externalUri = Uri.parse( + `${externalBase.toString(true).replace(/\/+$/, "")}${DOWNLOAD_ENDPOINT_PATH}?${DOWNLOAD_TOKEN_PARAM}=${token}`, + ); + // Timeout guards against the browser never making the request. + // Once the request arrives and streaming begins, settleResolve() + // is called from the request handler instead. + // Arm before openExternal so no window exists between open and guard. + timeoutId = setTimeout(() => { + settleReject( + new Error( + l10n.t( + "Timed out waiting for the browser to start the download.", + ), + ), + ); + }, BROWSER_CONNECTION_TIMEOUT_MS); + + const opened = await env.openExternal(externalUri); + + if (!opened) { + throw new Error(l10n.t("Failed to open browser download URL.")); + } + } catch (error) { + server.close(); + settleReject(error); + } + }); + }); +} diff --git a/client/src/components/LibraryNavigator/index.ts b/client/src/components/LibraryNavigator/index.ts index 1aea94ead..013255b4e 100644 --- a/client/src/components/LibraryNavigator/index.ts +++ b/client/src/components/LibraryNavigator/index.ts @@ -4,6 +4,7 @@ import { ConfigurationChangeEvent, Disposable, ExtensionContext, + UIKind, Uri, commands, env, @@ -13,7 +14,6 @@ import { } from "vscode"; import { createWriteStream } from "fs"; -import * as path from "path"; import { profileConfig } from "../../commands/profile"; import { Column } from "../../connection/rest/api/compute"; @@ -26,6 +26,7 @@ import LibraryAdapterFactory from "./LibraryAdapterFactory"; import LibraryDataProvider from "./LibraryDataProvider"; import LibraryModel from "./LibraryModel"; import PaginatedResultSet from "./PaginatedResultSet"; +import { streamTableToBrowserDownload } from "./browserDownload"; import { Messages } from "./const"; import { LibraryAdapter, LibraryItem, TableData } from "./types"; @@ -97,34 +98,59 @@ class LibraryNavigator implements SubscriptionProvider { commands.registerCommand( "SAS.downloadTable", async (item: LibraryItem) => { - let dataFilePath: string = ""; - if ( - env.remoteName !== undefined && - workspace.workspaceFolders && - workspace.workspaceFolders.length > 0 - ) { - // start from 'rootPath' workspace folder - dataFilePath = workspace.workspaceFolders[0].uri.fsPath; + const defaultFileName = + `${item.library}.${item.name}.csv`.toLocaleLowerCase(); + + // In web-enabled vscode distros, the native save dialog cannot write to the user's local + // file system, so skip it and stream directly to the browser. + // In this mode, the file will be downloaded to the browser's default download location. + if (env.uiKind === UIKind.Web) { + try { + await streamTableToBrowserDownload( + item, + defaultFileName, + this.libraryDataProvider, + ); + } catch (error) { + window.showErrorMessage( + l10n.t("Failed to download table: {error}", { + error: String( + error?.message || error || "Unknown error", + ).slice(0, 200), + }), + ); + } + return; } - dataFilePath = path.join( - dataFilePath, - `${item.library}.${item.name}.csv`.toLocaleLowerCase(), - ); - // display save file dialog - const uri = await window.showSaveDialog({ - defaultUri: Uri.file(dataFilePath), - }); + // Desktop mode: let the user pick a save location. + const defaultUri = + workspace.workspaceFolders && workspace.workspaceFolders.length > 0 + ? Uri.joinPath(workspace.workspaceFolders[0].uri, defaultFileName) + : Uri.file(defaultFileName); + const uri = await window.showSaveDialog({ defaultUri }); if (!uri) { return; } - const stream = createWriteStream(uri.fsPath); - await this.libraryDataProvider.writeTableContentsToStream( - stream, - item, - ); + if (uri.scheme === "file") { + try { + await this.libraryDataProvider.writeTableContentsToStream( + createWriteStream(uri.fsPath), + item, + ); + } catch (error) { + window.showErrorMessage( + l10n.t("Failed to download table: {error}", { + error: String( + error?.message || error || "Unknown error", + ).slice(0, 200), + }), + ); + } + return; + } }, ), commands.registerCommand( diff --git a/client/test/components/LibraryNavigator/LibraryModel.backpressure.test.ts b/client/test/components/LibraryNavigator/LibraryModel.backpressure.test.ts new file mode 100644 index 000000000..a2702fcf0 --- /dev/null +++ b/client/test/components/LibraryNavigator/LibraryModel.backpressure.test.ts @@ -0,0 +1,71 @@ +// Copyright © 2026, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { expect } from "chai"; +import * as sinon from "sinon"; +import { Writable } from "stream"; + +import { + stringArrayToCsvString, + writeWithBackpressure, +} from "../../../src/components/LibraryNavigator/LibraryModel"; + +describe("LibraryModel", function () { + describe("CSV Formatting", () => { + it("should escape double quotes in cell values", () => { + expect(stringArrayToCsvString(['value with "quotes"'])).to.equal( + '"value with ""quotes"""', + ); + }); + + it("should coerce null cells to empty string", () => { + expect(stringArrayToCsvString([null])).to.equal('""'); + }); + + it("should coerce numeric cells to string", () => { + expect(stringArrayToCsvString([12345])).to.equal('"12345"'); + }); + + it("should format multiple cells as a quoted CSV row", () => { + expect(stringArrayToCsvString(["col1", "col2", "col3"])).to.equal( + '"col1","col2","col3"', + ); + }); + }); + + describe("writeWithBackpressure", () => { + it("should write data and resolve immediately when write returns true", async () => { + const written: string[] = []; + const stream = new Writable({ + write(chunk, _enc, cb) { + written.push(chunk.toString()); + cb(); + }, + }); + + await writeWithBackpressure(stream, "hello"); + expect(written).to.deep.equal(["hello"]); + }); + + it("should wait for drain before resolving when write returns false", async () => { + const stream = new Writable({ + write(_chunk, _enc, cb) { + cb(); + }, + }); + const stub = sinon.stub(stream, "write").callsFake(() => { + setTimeout(() => stream.emit("drain"), 0); + return false; + }); + + let resolved = false; + const p = writeWithBackpressure(stream, "data").then(() => { + resolved = true; + }); + + expect(resolved).to.be.false; + await p; + expect(resolved).to.be.true; + stub.restore(); + }); + }); +}); diff --git a/client/test/components/LibraryNavigator/LibraryNavigator.test.ts b/client/test/components/LibraryNavigator/LibraryNavigator.test.ts new file mode 100644 index 000000000..2db3cac8b --- /dev/null +++ b/client/test/components/LibraryNavigator/LibraryNavigator.test.ts @@ -0,0 +1,204 @@ +// Copyright © 2026, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { expect } from "chai"; +import { randomBytes } from "crypto"; + +import { + classifyDownloadRequest, + isValidDownloadToken, + sanitizeDownloadFilename, +} from "../../../src/components/LibraryNavigator/browserDownload"; + +const MAX_LENGTH = 250; +const FALLBACK = "table.csv"; + +describe("LibraryNavigator - Browser Download", function () { + describe("Filename Sanitization", () => { + it("should remove control characters from filename", () => { + expect( + sanitizeDownloadFilename( + "test\x00\x01\x1ffile.csv", + MAX_LENGTH, + FALLBACK, + ), + ).to.equal("testfile.csv"); + }); + + it("should remove quotes from filename", () => { + expect( + sanitizeDownloadFilename('test"file".csv', MAX_LENGTH, FALLBACK), + ).to.equal("testfile.csv"); + }); + + it("should collapse multiple dots to prevent path traversal", () => { + expect( + sanitizeDownloadFilename("test..file...csv", MAX_LENGTH, FALLBACK), + ).to.equal("test.file.csv"); + }); + + it("should remove leading dots", () => { + expect( + sanitizeDownloadFilename("...test.csv", MAX_LENGTH, FALLBACK), + ).to.equal("test.csv"); + }); + + it("should preserve Unicode characters", () => { + expect( + sanitizeDownloadFilename("テスト日本語.csv", MAX_LENGTH, FALLBACK), + ).to.equal("テスト日本語.csv"); + }); + + it("should limit filename to max length", () => { + const result = sanitizeDownloadFilename( + "a".repeat(300) + ".csv", + MAX_LENGTH, + FALLBACK, + ); + expect(result.length).to.equal(MAX_LENGTH); + }); + + it("should return fallback when sanitization results in empty string", () => { + expect( + sanitizeDownloadFilename("\x00\x01\x1f", MAX_LENGTH, FALLBACK), + ).to.equal(FALLBACK); + }); + }); + + describe("Token Validation", () => { + it("should accept a valid token", () => { + const token = randomBytes(24).toString("hex"); + expect(isValidDownloadToken(token, token)).to.be.true; + }); + + it("should reject a token of wrong length", () => { + const token = randomBytes(24).toString("hex"); + expect(isValidDownloadToken(token.slice(0, -1), token)).to.be.false; + }); + + it("should reject a null token", () => { + const token = randomBytes(24).toString("hex"); + expect(isValidDownloadToken(null, token)).to.be.false; + }); + + it("should reject a token with correct length but wrong value", () => { + const token = randomBytes(24).toString("hex"); + const wrong = "x".repeat(token.length); + expect(isValidDownloadToken(wrong, token)).to.be.false; + }); + }); + + describe("Download Request Classification", () => { + const TOKEN = randomBytes(24).toString("hex"); + const ENDPOINT = "/sas-library-download"; + const TOKEN_PARAM = "token"; + + it("should reject non-GET methods with 405", () => { + const result = classifyDownloadRequest( + "POST", + `${ENDPOINT}?${TOKEN_PARAM}=${TOKEN}`, + TOKEN, + ENDPOINT, + TOKEN_PARAM, + false, + ); + expect(result.outcome).to.equal(405); + }); + + it("should reject a locked server with 410", () => { + const result = classifyDownloadRequest( + "GET", + `${ENDPOINT}?${TOKEN_PARAM}=${TOKEN}`, + TOKEN, + ENDPOINT, + TOKEN_PARAM, + true, + ); + expect(result.outcome).to.equal(410); + }); + + it("should reject an invalid token with 404", () => { + const result = classifyDownloadRequest( + "GET", + `${ENDPOINT}?${TOKEN_PARAM}=badtoken`, + TOKEN, + ENDPOINT, + TOKEN_PARAM, + false, + ); + expect(result.outcome).to.equal(404); + }); + + it("should reject a wrong path with 404", () => { + const result = classifyDownloadRequest( + "GET", + `/other?${TOKEN_PARAM}=${TOKEN}`, + TOKEN, + ENDPOINT, + TOKEN_PARAM, + false, + ); + expect(result.outcome).to.equal(404); + }); + + it("should reject a missing URL with 404", () => { + const result = classifyDownloadRequest( + "GET", + undefined, + TOKEN, + ENDPOINT, + TOKEN_PARAM, + false, + ); + expect(result.outcome).to.equal(404); + }); + + it("should allow a valid GET request with matching token and path", () => { + const result = classifyDownloadRequest( + "GET", + `${ENDPOINT}?${TOKEN_PARAM}=${TOKEN}`, + TOKEN, + ENDPOINT, + TOKEN_PARAM, + false, + ); + expect(result.outcome).to.equal("stream"); + }); + }); + + describe("Request Lock Protection", () => { + it("should block a second request once the first acquires the lock", () => { + let requestLock = false; + + const canProcess = !requestLock; + if (canProcess) { + requestLock = true; + } + + expect(canProcess).to.be.true; + expect(!requestLock).to.be.false; + }); + }); + + describe("Settle Pattern Idempotency", () => { + it("settleResolve should clear timeout to prevent spurious timeout logs", () => { + let timeoutId: NodeJS.Timeout | undefined = setTimeout(() => {}, 1000); + let settled = false; + + const settleResolve = () => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + }; + + settleResolve(); + + expect(timeoutId).to.be.undefined; + expect(settled).to.be.true; + }); + }); +});