Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions client/src/components/LibraryNavigator/LibraryModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const canContinue = stream.write(data);
if (!canContinue) {
await new Promise<void>((resolve) => stream.once("drain", resolve));
}
}

class LibraryModel {
public constructor(protected libraryAdapter: LibraryAdapter | undefined) {}

Expand Down Expand Up @@ -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(
{
Expand All @@ -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);
Expand Down
225 changes: 225 additions & 0 deletions client/src/components/LibraryNavigator/browserDownload.ts
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love that we're only exposing these functions so that we can test them. I'd prefer just running all tests through what's available in the public interface, but I'm guessing it's likely easier this way and requires less setup

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<void> {
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<void>((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);
}
});
});
}
70 changes: 48 additions & 22 deletions client/src/components/LibraryNavigator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ConfigurationChangeEvent,
Disposable,
ExtensionContext,
UIKind,
Uri,
commands,
env,
Expand All @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading