Add unit tests for interface-utils.ts
Also, some moving around of functions and whitespace changes.
This commit is contained in:
@@ -18,10 +18,7 @@ import {
|
||||
import { Logger } from "../logging";
|
||||
import { CodeQLCliServer } from "../cli";
|
||||
import { DatabaseManager } from "../databases";
|
||||
import {
|
||||
getHtmlForWebview,
|
||||
jumpToLocation,
|
||||
} from "../webview-utils";
|
||||
import { getHtmlForWebview, jumpToLocation } from "../interface-utils";
|
||||
import { adaptSchema, adaptBqrs, RawResultSet } from "../adapt";
|
||||
import { BQRSInfo } from "../bqrs-cli-types";
|
||||
import resultsDiff from "./resultsDiff";
|
||||
@@ -190,7 +187,8 @@ export class CompareInterfaceManager extends DisposableObject {
|
||||
const commonResultSetNames = fromSchemaNames.filter((name) =>
|
||||
toSchemaNames.includes(name)
|
||||
);
|
||||
const currentResultSetName = selectedResultSetName || commonResultSetNames[0];
|
||||
const currentResultSetName =
|
||||
selectedResultSetName || commonResultSetNames[0];
|
||||
const fromResultSet = await this.getResultSet(
|
||||
fromSchemas,
|
||||
currentResultSetName,
|
||||
@@ -213,7 +211,11 @@ export class CompareInterfaceManager extends DisposableObject {
|
||||
if (!this.comparePair?.from || !this.comparePair.to) {
|
||||
return;
|
||||
}
|
||||
await this.showResults(this.comparePair.from, this.comparePair.to, newResultSetName);
|
||||
await this.showResults(
|
||||
this.comparePair.from,
|
||||
this.comparePair.to,
|
||||
newResultSetName
|
||||
);
|
||||
}
|
||||
|
||||
private async getResultSet(
|
||||
|
||||
@@ -22,7 +22,7 @@ import * as helpers from './helpers';
|
||||
import { assertNever } from './helpers-pure';
|
||||
import { spawnIdeServer } from './ide-server';
|
||||
import { InterfaceManager } from './interface';
|
||||
import { WebviewReveal } from './webview-utils';
|
||||
import { WebviewReveal } from './interface-utils';
|
||||
import { ideServerLogger, logger, queryServerLogger } from './logging';
|
||||
import { QueryHistoryManager } from './query-history';
|
||||
import { CompletedQuery } from './query-results';
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import * as sarif from 'sarif';
|
||||
import { ResolvableLocationValue, ColumnSchema } from 'semmle-bqrs';
|
||||
import { ResultRow, ParsedResultSets } from './adapt';
|
||||
import {
|
||||
ResolvableLocationValue,
|
||||
ColumnSchema,
|
||||
ResultSetSchema,
|
||||
} from "semmle-bqrs";
|
||||
import { ResultRow, ParsedResultSets, RawResultSet } from './adapt';
|
||||
|
||||
/**
|
||||
* This module contains types and code that are shared between
|
||||
* the webview and the extension.
|
||||
*/
|
||||
|
||||
export const SELECT_TABLE_NAME = "#select";
|
||||
export const ALERTS_TABLE_NAME = "alerts";
|
||||
|
||||
export type RawTableResultSet = { t: "RawResultSet" } & RawResultSet;
|
||||
export type PathTableResultSet = {
|
||||
t: "SarifResultSet";
|
||||
readonly schema: ResultSetSchema;
|
||||
name: string;
|
||||
} & Interpretation;
|
||||
|
||||
export type ResultSet = RawTableResultSet | PathTableResultSet;
|
||||
|
||||
/**
|
||||
* Only ever show this many results per run in interpreted results.
|
||||
@@ -228,3 +249,26 @@ export type QueryCompareResult = {
|
||||
from: ResultRow[];
|
||||
to: ResultRow[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the name of the default result. Prefer returning
|
||||
* 'alerts', or '#select'. Otherwise return the first in the list.
|
||||
*
|
||||
* Note that this is the only function in this module. It must be
|
||||
* placed here since it is shared across the webview boundary.
|
||||
*
|
||||
* We should consider moving to a separate module to ensure this
|
||||
* one is types only.
|
||||
*
|
||||
* @param resultSetNames
|
||||
*/
|
||||
export function getDefaultResultSetName(
|
||||
resultSetNames: readonly string[]
|
||||
): string {
|
||||
// Choose first available result set from the array
|
||||
return [
|
||||
ALERTS_TABLE_NAME,
|
||||
SELECT_TABLE_NAME,
|
||||
resultSetNames[0],
|
||||
].filter((resultSetName) => resultSetNames.includes(resultSetName))[0];
|
||||
}
|
||||
|
||||
@@ -1,32 +1,233 @@
|
||||
import { RawResultSet } from './adapt';
|
||||
import { ResultSetSchema } from 'semmle-bqrs';
|
||||
import { Interpretation } from './interface-types';
|
||||
import * as crypto from "crypto";
|
||||
import {
|
||||
Uri,
|
||||
Location,
|
||||
Range,
|
||||
WebviewPanel,
|
||||
Webview,
|
||||
workspace,
|
||||
window as Window,
|
||||
ViewColumn,
|
||||
Selection,
|
||||
TextEditorRevealType,
|
||||
ThemeColor,
|
||||
} from "vscode";
|
||||
import {
|
||||
FivePartLocation,
|
||||
LocationStyle,
|
||||
LocationValue,
|
||||
tryGetResolvableLocation,
|
||||
WholeFileLocation,
|
||||
ResolvableLocationValue,
|
||||
} from "semmle-bqrs";
|
||||
import { DatabaseItem, DatabaseManager } from "./databases";
|
||||
import { ViewSourceFileMsg } from "./interface-types";
|
||||
import { Logger } from "./logging";
|
||||
|
||||
export const SELECT_TABLE_NAME = "#select";
|
||||
export const ALERTS_TABLE_NAME = "alerts";
|
||||
/**
|
||||
* This module contains functions and types that are sharedd between
|
||||
* interface.ts and compare-interface.ts.
|
||||
*/
|
||||
|
||||
export type RawTableResultSet = { t: "RawResultSet" } & RawResultSet;
|
||||
export type PathTableResultSet = {
|
||||
t: "SarifResultSet";
|
||||
readonly schema: ResultSetSchema;
|
||||
name: string;
|
||||
} & Interpretation;
|
||||
|
||||
export type ResultSet = RawTableResultSet | PathTableResultSet;
|
||||
|
||||
export function getDefaultResultSet(resultSets: readonly ResultSet[]): string {
|
||||
return getDefaultResultSetName(
|
||||
resultSets.map((resultSet) => resultSet.schema.name)
|
||||
);
|
||||
/** Gets a nonce string created with 128 bits of entropy. */
|
||||
export function getNonce(): string {
|
||||
return crypto.randomBytes(16).toString("base64");
|
||||
}
|
||||
|
||||
export function getDefaultResultSetName(
|
||||
resultSetNames: readonly string[]
|
||||
/**
|
||||
* Whether to force webview to reveal
|
||||
*/
|
||||
export enum WebviewReveal {
|
||||
Forced,
|
||||
NotForced,
|
||||
}
|
||||
|
||||
/** Converts a filesystem URI into a webview URI string that the given panel can use to read the file. */
|
||||
export function fileUriToWebviewUri(
|
||||
panel: WebviewPanel,
|
||||
fileUriOnDisk: Uri
|
||||
): string {
|
||||
// Choose first available result set from the array
|
||||
return [
|
||||
ALERTS_TABLE_NAME,
|
||||
SELECT_TABLE_NAME,
|
||||
resultSetNames[0],
|
||||
].filter((resultSetName) => resultSetNames.includes(resultSetName))[0];
|
||||
return panel.webview.asWebviewUri(fileUriOnDisk).toString();
|
||||
}
|
||||
|
||||
/** Converts a URI string received from a webview into a local filesystem URI for the same resource. */
|
||||
export function webviewUriToFileUri(webviewUri: string): Uri {
|
||||
// Webview URIs used the vscode-resource scheme. The filesystem path of the resource can be obtained from the path component of the webview URI.
|
||||
const path = Uri.parse(webviewUri).path;
|
||||
// For this path to be interpreted on the filesystem, we need to parse it as a filesystem URI for the current platform.
|
||||
return Uri.file(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the specified CodeQL location to a URI into the source archive.
|
||||
* @param loc CodeQL location to resolve. Must have a non-empty value for `loc.file`.
|
||||
* @param databaseItem Database in which to resolve the file location.
|
||||
*/
|
||||
function resolveFivePartLocation(
|
||||
loc: FivePartLocation,
|
||||
databaseItem: DatabaseItem
|
||||
): Location {
|
||||
// `Range` is a half-open interval, and is zero-based. CodeQL locations are closed intervals, and
|
||||
// are one-based. Adjust accordingly.
|
||||
const range = new Range(
|
||||
Math.max(0, loc.lineStart - 1),
|
||||
Math.max(0, loc.colStart - 1),
|
||||
Math.max(0, loc.lineEnd - 1),
|
||||
Math.max(0, loc.colEnd)
|
||||
);
|
||||
|
||||
return new Location(databaseItem.resolveSourceFile(loc.file), range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the specified CodeQL filesystem resource location to a URI into the source archive.
|
||||
* @param loc CodeQL location to resolve, corresponding to an entire filesystem resource. Must have a non-empty value for `loc.file`.
|
||||
* @param databaseItem Database in which to resolve the filesystem resource location.
|
||||
*/
|
||||
function resolveWholeFileLocation(
|
||||
loc: WholeFileLocation,
|
||||
databaseItem: DatabaseItem
|
||||
): Location {
|
||||
// A location corresponding to the start of the file.
|
||||
const range = new Range(0, 0, 0, 0);
|
||||
return new Location(databaseItem.resolveSourceFile(loc.file), range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve the specified CodeQL location to a URI into the source archive. If no exact location
|
||||
* can be resolved, returns `undefined`.
|
||||
* @param loc CodeQL location to resolve
|
||||
* @param databaseItem Database in which to resolve the file location.
|
||||
*/
|
||||
export function tryResolveLocation(
|
||||
loc: LocationValue | undefined,
|
||||
databaseItem: DatabaseItem
|
||||
): Location | undefined {
|
||||
const resolvableLoc = tryGetResolvableLocation(loc);
|
||||
if (resolvableLoc === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
switch (resolvableLoc.t) {
|
||||
case LocationStyle.FivePart:
|
||||
return resolveFivePartLocation(resolvableLoc, databaseItem);
|
||||
case LocationStyle.WholeFile:
|
||||
return resolveWholeFileLocation(resolvableLoc, databaseItem);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTML to populate the given webview.
|
||||
* Uses a content security policy that only loads the given script.
|
||||
*/
|
||||
export function getHtmlForWebview(
|
||||
webview: Webview,
|
||||
scriptUriOnDisk: Uri,
|
||||
stylesheetUriOnDisk: Uri
|
||||
): string {
|
||||
// Convert the on-disk URIs into webview URIs.
|
||||
const scriptWebviewUri = webview.asWebviewUri(scriptUriOnDisk);
|
||||
const stylesheetWebviewUri = webview.asWebviewUri(stylesheetUriOnDisk);
|
||||
// Use a nonce in the content security policy to uniquely identify the above resources.
|
||||
const nonce = getNonce();
|
||||
/*
|
||||
* Content security policy:
|
||||
* default-src: allow nothing by default.
|
||||
* script-src: allow only the given script, using the nonce.
|
||||
* style-src: allow only the given stylesheet, using the nonce.
|
||||
* connect-src: only allow fetch calls to webview resource URIs
|
||||
* (this is used to load BQRS result files).
|
||||
*/
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src ${webview.cspSource};">
|
||||
<link nonce="${nonce}" rel="stylesheet" href="${stylesheetWebviewUri}">
|
||||
</head>
|
||||
<body>
|
||||
<div id=root>
|
||||
</div>
|
||||
<script nonce="${nonce}" src="${scriptWebviewUri}">
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function showLocation(
|
||||
loc: ResolvableLocationValue,
|
||||
databaseItem: DatabaseItem
|
||||
): Promise<void> {
|
||||
const resolvedLocation = tryResolveLocation(loc, databaseItem);
|
||||
if (resolvedLocation) {
|
||||
const doc = await workspace.openTextDocument(resolvedLocation.uri);
|
||||
const editorsWithDoc = Window.visibleTextEditors.filter(
|
||||
(e) => e.document === doc
|
||||
);
|
||||
const editor =
|
||||
editorsWithDoc.length > 0
|
||||
? editorsWithDoc[0]
|
||||
: await Window.showTextDocument(doc, ViewColumn.One);
|
||||
const range = resolvedLocation.range;
|
||||
// When highlighting the range, vscode's occurrence-match and bracket-match highlighting will
|
||||
// trigger based on where we place the cursor/selection, and will compete for the user's attention.
|
||||
// For reference:
|
||||
// - Occurences are highlighted when the cursor is next to or inside a word or a whole word is selected.
|
||||
// - Brackets are highlighted when the cursor is next to a bracket and there is an empty selection.
|
||||
// - Multi-line selections explicitly highlight line-break characters, but multi-line decorators do not.
|
||||
//
|
||||
// For single-line ranges, select the whole range, mainly to disable bracket highlighting.
|
||||
// For multi-line ranges, place the cursor at the beginning to avoid visual artifacts from selected line-breaks.
|
||||
// Multi-line ranges are usually large enough to overshadow the noise from bracket highlighting.
|
||||
const selectionEnd =
|
||||
range.start.line === range.end.line ? range.end : range.start;
|
||||
editor.selection = new Selection(range.start, selectionEnd);
|
||||
editor.revealRange(range, TextEditorRevealType.InCenter);
|
||||
editor.setDecorations(shownLocationDecoration, [range]);
|
||||
editor.setDecorations(shownLocationLineDecoration, [range]);
|
||||
}
|
||||
}
|
||||
|
||||
const findMatchBackground = new ThemeColor("editor.findMatchBackground");
|
||||
const findRangeHighlightBackground = new ThemeColor(
|
||||
"editor.findRangeHighlightBackground"
|
||||
);
|
||||
|
||||
export const shownLocationDecoration = Window.createTextEditorDecorationType({
|
||||
backgroundColor: findMatchBackground,
|
||||
});
|
||||
|
||||
export const shownLocationLineDecoration = Window.createTextEditorDecorationType(
|
||||
{
|
||||
backgroundColor: findRangeHighlightBackground,
|
||||
isWholeLine: true,
|
||||
}
|
||||
);
|
||||
|
||||
export async function jumpToLocation(
|
||||
msg: ViewSourceFileMsg,
|
||||
databaseManager: DatabaseManager,
|
||||
logger: Logger
|
||||
) {
|
||||
const databaseItem = databaseManager.findDatabaseItem(
|
||||
Uri.parse(msg.databaseUri)
|
||||
);
|
||||
if (databaseItem !== undefined) {
|
||||
try {
|
||||
await showLocation(msg.loc, databaseItem);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
if (e.message.match(/File not found/)) {
|
||||
Window.showErrorMessage(
|
||||
`Original file of this result is not in the database's source archive.`
|
||||
);
|
||||
} else {
|
||||
logger.log(`Unable to handleMsgFromView: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
logger.log(`Unable to handleMsgFromView: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ import {
|
||||
shownLocationDecoration,
|
||||
shownLocationLineDecoration,
|
||||
jumpToLocation,
|
||||
} from "./webview-utils";
|
||||
import { getDefaultResultSetName } from "./interface-utils";
|
||||
} from "./interface-utils";
|
||||
import { getDefaultResultSetName } from "./interface-types";
|
||||
|
||||
/**
|
||||
* interface.ts
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LocationStyle } from 'semmle-bqrs';
|
||||
import * as octicons from './octicons';
|
||||
import { className, renderLocation, ResultTableProps, zebraStripe, selectableZebraStripe, jumpToLocation, nextSortDirection } from './result-table-utils';
|
||||
import { onNavigation, NavigationEvent } from './results';
|
||||
import { PathTableResultSet } from '../interface-utils';
|
||||
import { PathTableResultSet } from '../interface-types';
|
||||
import { parseSarifPlainTextMessage, parseSarifLocation } from '../sarif-utils';
|
||||
import { InterpretedResultsSortColumn, SortDirection, InterpretedResultsSortState } from '../interface-types';
|
||||
import { vscode } from './vscode-api';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { ResultTableProps, className } from "./result-table-utils";
|
||||
import { RAW_RESULTS_LIMIT, RawResultsSortState } from "../interface-types";
|
||||
import { RawTableResultSet } from "../interface-utils";
|
||||
import { RawTableResultSet } from "../interface-types";
|
||||
import RawTableHeader from "./RawTableHeader";
|
||||
import RawTableRow from "./RawTableRow";
|
||||
import { ResultRow } from "../adapt";
|
||||
|
||||
export type RawTableProps = ResultTableProps & {
|
||||
resultSet: RawTableResultSet;
|
||||
@@ -26,7 +27,7 @@ export class RawTable extends React.Component<RawTableProps, {}> {
|
||||
dataRows = dataRows.slice(0, RAW_RESULTS_LIMIT);
|
||||
}
|
||||
|
||||
const tableRows = dataRows.map((row, rowIndex) =>
|
||||
const tableRows = dataRows.map((row: ResultRow, rowIndex: number) =>
|
||||
<RawTableRow
|
||||
key={rowIndex}
|
||||
rowIndex={rowIndex}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { LocationValue, ResolvableLocationValue, tryGetResolvableLocation } from 'semmle-bqrs';
|
||||
import { RawResultsSortState, QueryMetadata, SortDirection } from '../interface-types';
|
||||
import { assertNever } from '../helpers-pure';
|
||||
import { ResultSet } from '../interface-utils';
|
||||
import { ResultSet } from '../interface-types';
|
||||
import { vscode } from './vscode-api';
|
||||
|
||||
export interface ResultTableProps {
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { DatabaseInfo, Interpretation, RawResultsSortState, QueryMetadata, ResultsPaths, InterpretedResultsSortState, RAW_RESULTS_PAGE_SIZE } from '../interface-types';
|
||||
import {
|
||||
DatabaseInfo,
|
||||
Interpretation,
|
||||
RawResultsSortState,
|
||||
QueryMetadata,
|
||||
ResultsPaths,
|
||||
InterpretedResultsSortState,
|
||||
RAW_RESULTS_PAGE_SIZE,
|
||||
ResultSet,
|
||||
ALERTS_TABLE_NAME,
|
||||
SELECT_TABLE_NAME,
|
||||
getDefaultResultSetName,
|
||||
} from "../interface-types";
|
||||
import { PathTable } from './alert-table';
|
||||
import { RawTable } from './raw-results-table';
|
||||
import { ResultTableProps, tableSelectionHeaderClassName, toggleDiagnosticsClassName, alertExtrasClassName } from './result-table-utils';
|
||||
import { ParsedResultSets, ExtensionParsedResultSets } from '../adapt';
|
||||
import { ResultSet, ALERTS_TABLE_NAME, SELECT_TABLE_NAME, getDefaultResultSet } from '../interface-utils';
|
||||
import { vscode } from './vscode-api';
|
||||
|
||||
|
||||
/**
|
||||
* Properties for the `ResultTables` component.
|
||||
*/
|
||||
@@ -274,6 +286,14 @@ class ResultTable extends React.Component<ResultTableProps, {}> {
|
||||
{...this.props} resultSet={resultSet} />;
|
||||
case 'SarifResultSet': return <PathTable
|
||||
{...this.props} resultSet={resultSet} />;
|
||||
default:
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultResultSet(resultSets: readonly ResultSet[]): string {
|
||||
return getDefaultResultSetName(
|
||||
resultSets.map((resultSet) => resultSet.schema.name)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
ResultRow,
|
||||
ParsedResultSets,
|
||||
} from "../adapt";
|
||||
import { ResultSet } from "../interface-utils";
|
||||
import { ResultSet } from "../interface-types";
|
||||
import { vscode } from "./vscode-api";
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect } from "chai";
|
||||
import * as vscode from "vscode";
|
||||
import * as path from "path";
|
||||
import * as sinon from "sinon";
|
||||
import * as tmp from "tmp";
|
||||
import { window, ViewColumn, Uri } from "vscode";
|
||||
import {
|
||||
fileUriToWebviewUri,
|
||||
webviewUriToFileUri,
|
||||
tryResolveLocation,
|
||||
} from "../../interface-utils";
|
||||
import { getDefaultResultSetName } from "../../interface-types";
|
||||
import { LocationStyle } from "semmle-bqrs";
|
||||
import { DatabaseItem } from "../../databases";
|
||||
|
||||
describe("interface-utils", () => {
|
||||
describe("webview uri conversion", function () {
|
||||
const fileSuffix = ".bqrs";
|
||||
|
||||
function setupWebview(filePrefix: string) {
|
||||
const tmpFile = tmp.fileSync({
|
||||
prefix: `uri_test_${filePrefix}_`,
|
||||
postfix: fileSuffix,
|
||||
keep: false,
|
||||
});
|
||||
const fileUriOnDisk = Uri.file(tmpFile.name);
|
||||
const panel = window.createWebviewPanel(
|
||||
"test panel",
|
||||
"test panel",
|
||||
ViewColumn.Beside,
|
||||
{
|
||||
enableScripts: false,
|
||||
localResourceRoots: [fileUriOnDisk],
|
||||
}
|
||||
);
|
||||
after(function () {
|
||||
panel.dispose();
|
||||
tmpFile.removeCallback();
|
||||
});
|
||||
|
||||
// CSP allowing nothing, to prevent warnings.
|
||||
const html = `<html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none';"></head></html>`;
|
||||
panel.webview.html = html;
|
||||
return {
|
||||
fileUriOnDisk,
|
||||
panel,
|
||||
};
|
||||
}
|
||||
|
||||
it("should correctly round trip from filesystem to webview and back", function () {
|
||||
const { fileUriOnDisk, panel } = setupWebview("");
|
||||
const webviewUri = fileUriToWebviewUri(panel, fileUriOnDisk);
|
||||
const reconstructedFileUri = webviewUriToFileUri(webviewUri);
|
||||
expect(reconstructedFileUri.toString(true)).to.equal(
|
||||
fileUriOnDisk.toString(true)
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-encode # in URIs", function () {
|
||||
const { fileUriOnDisk, panel } = setupWebview("#");
|
||||
const webviewUri = fileUriToWebviewUri(panel, fileUriOnDisk);
|
||||
const parsedUri = Uri.parse(webviewUri);
|
||||
expect(path.basename(parsedUri.path, fileSuffix)).to.equal(
|
||||
path.basename(fileUriOnDisk.path, fileSuffix)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultResultSetName", () => {
|
||||
it("should get the default name", () => {
|
||||
expect(getDefaultResultSetName(["a", "b", "#select", "alerts"])).to.equal(
|
||||
"alerts"
|
||||
);
|
||||
expect(getDefaultResultSetName(["a", "b", "#select"])).to.equal(
|
||||
"#select"
|
||||
);
|
||||
expect(getDefaultResultSetName(["a", "b"])).to.equal("a");
|
||||
expect(getDefaultResultSetName([])).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWholeFileLocation", () => {
|
||||
it("should resolve a whole file location", () => {
|
||||
const mockDatabaseItem: DatabaseItem = ({
|
||||
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
|
||||
} as unknown) as DatabaseItem;
|
||||
expect(
|
||||
tryResolveLocation(
|
||||
{
|
||||
t: LocationStyle.WholeFile,
|
||||
file: "hucairz",
|
||||
},
|
||||
mockDatabaseItem
|
||||
)
|
||||
).to.deep.equal(
|
||||
new vscode.Location(
|
||||
vscode.Uri.parse("abc"),
|
||||
new vscode.Range(0, 0, 0, 0)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve a five-part location", () => {
|
||||
const mockDatabaseItem: DatabaseItem = ({
|
||||
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
|
||||
} as unknown) as DatabaseItem;
|
||||
|
||||
expect(
|
||||
tryResolveLocation(
|
||||
{
|
||||
t: LocationStyle.FivePart,
|
||||
colStart: 1,
|
||||
colEnd: 3,
|
||||
lineStart: 4,
|
||||
lineEnd: 5,
|
||||
file: "hucairz",
|
||||
},
|
||||
mockDatabaseItem
|
||||
)
|
||||
).to.deep.equal(
|
||||
new vscode.Location(
|
||||
vscode.Uri.parse("abc"),
|
||||
new vscode.Range(new vscode.Position(4, 3), new vscode.Position(3, 0))
|
||||
)
|
||||
);
|
||||
expect(mockDatabaseItem.resolveSourceFile).to.have.been.calledOnceWith(
|
||||
"hucairz"
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve a string location for whole file", () => {
|
||||
const mockDatabaseItem: DatabaseItem = ({
|
||||
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
|
||||
} as unknown) as DatabaseItem;
|
||||
|
||||
expect(
|
||||
tryResolveLocation(
|
||||
{
|
||||
t: LocationStyle.String,
|
||||
loc: "file://hucairz:0:0:0:0"
|
||||
},
|
||||
mockDatabaseItem
|
||||
)
|
||||
).to.deep.equal(
|
||||
new vscode.Location(
|
||||
vscode.Uri.parse("abc"),
|
||||
new vscode.Range(0, 0, 0, 0)
|
||||
)
|
||||
);
|
||||
expect(mockDatabaseItem.resolveSourceFile).to.have.been.calledOnceWith(
|
||||
"hucairz"
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve a string location for five-part location", () => {
|
||||
const mockDatabaseItem: DatabaseItem = ({
|
||||
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
|
||||
} as unknown) as DatabaseItem;
|
||||
|
||||
expect(
|
||||
tryResolveLocation(
|
||||
{
|
||||
t: LocationStyle.String,
|
||||
loc: "file://hucairz:5:4:3:2"
|
||||
},
|
||||
mockDatabaseItem
|
||||
)
|
||||
).to.deep.equal(
|
||||
new vscode.Location(
|
||||
vscode.Uri.parse("abc"),
|
||||
new vscode.Range(new vscode.Position(4, 3), new vscode.Position(2, 2))
|
||||
)
|
||||
);
|
||||
expect(mockDatabaseItem.resolveSourceFile).to.have.been.calledOnceWith(
|
||||
"hucairz"
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve a string location for invalid string", () => {
|
||||
const mockDatabaseItem: DatabaseItem = ({
|
||||
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
|
||||
} as unknown) as DatabaseItem;
|
||||
|
||||
expect(
|
||||
tryResolveLocation(
|
||||
{
|
||||
t: LocationStyle.String,
|
||||
loc: "file://hucairz:x:y:z:a"
|
||||
},
|
||||
mockDatabaseItem
|
||||
)
|
||||
).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect } from "chai";
|
||||
import * as path from "path";
|
||||
import * as tmp from "tmp";
|
||||
import { window, ViewColumn, Uri } from "vscode";
|
||||
import { fileUriToWebviewUri, webviewUriToFileUri } from '../../webview-utils';
|
||||
|
||||
describe('webview uri conversion', function() {
|
||||
const fileSuffix = '.bqrs';
|
||||
|
||||
function setupWebview(filePrefix: string) {
|
||||
const tmpFile = tmp.fileSync({ prefix: `uri_test_${filePrefix}_`, postfix: fileSuffix, keep: false });
|
||||
const fileUriOnDisk = Uri.file(tmpFile.name);
|
||||
const panel = window.createWebviewPanel(
|
||||
'test panel',
|
||||
'test panel',
|
||||
ViewColumn.Beside,
|
||||
{
|
||||
enableScripts: false,
|
||||
localResourceRoots: [
|
||||
fileUriOnDisk
|
||||
]
|
||||
}
|
||||
);
|
||||
after(function() {
|
||||
panel.dispose();
|
||||
tmpFile.removeCallback();
|
||||
});
|
||||
|
||||
// CSP allowing nothing, to prevent warnings.
|
||||
const html = '<html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\';"></head></html>';
|
||||
panel.webview.html = html;
|
||||
return {
|
||||
fileUriOnDisk,
|
||||
panel
|
||||
};
|
||||
}
|
||||
|
||||
it('should correctly round trip from filesystem to webview and back', function() {
|
||||
const { fileUriOnDisk, panel } = setupWebview('');
|
||||
const webviewUri = fileUriToWebviewUri(panel, fileUriOnDisk);
|
||||
const reconstructedFileUri = webviewUriToFileUri(webviewUri);
|
||||
expect(reconstructedFileUri.toString(true)).to.equal(fileUriOnDisk.toString(true));
|
||||
});
|
||||
|
||||
it('does not double-encode # in URIs', function() {
|
||||
const { fileUriOnDisk, panel } = setupWebview('#');
|
||||
const webviewUri = fileUriToWebviewUri(panel, fileUriOnDisk);
|
||||
const parsedUri = Uri.parse(webviewUri);
|
||||
expect(path.basename(parsedUri.path, fileSuffix)).to.equal(path.basename(fileUriOnDisk.path, fileSuffix));
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import * as crypto from "crypto";
|
||||
import {
|
||||
Uri,
|
||||
Location,
|
||||
Range,
|
||||
WebviewPanel,
|
||||
Webview,
|
||||
workspace,
|
||||
window as Window,
|
||||
ViewColumn,
|
||||
Selection,
|
||||
TextEditorRevealType,
|
||||
ThemeColor,
|
||||
} from "vscode";
|
||||
import {
|
||||
FivePartLocation,
|
||||
LocationStyle,
|
||||
LocationValue,
|
||||
tryGetResolvableLocation,
|
||||
WholeFileLocation,
|
||||
ResolvableLocationValue,
|
||||
} from "semmle-bqrs";
|
||||
import { DatabaseItem, DatabaseManager } from "./databases";
|
||||
import { ViewSourceFileMsg } from "./interface-types";
|
||||
import { Logger } from "./logging";
|
||||
|
||||
/** Gets a nonce string created with 128 bits of entropy. */
|
||||
export function getNonce(): string {
|
||||
return crypto.randomBytes(16).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to force webview to reveal
|
||||
*/
|
||||
export enum WebviewReveal {
|
||||
Forced,
|
||||
NotForced,
|
||||
}
|
||||
|
||||
/** Converts a filesystem URI into a webview URI string that the given panel can use to read the file. */
|
||||
export function fileUriToWebviewUri(
|
||||
panel: WebviewPanel,
|
||||
fileUriOnDisk: Uri
|
||||
): string {
|
||||
return panel.webview.asWebviewUri(fileUriOnDisk).toString();
|
||||
}
|
||||
|
||||
/** Converts a URI string received from a webview into a local filesystem URI for the same resource. */
|
||||
export function webviewUriToFileUri(webviewUri: string): Uri {
|
||||
// Webview URIs used the vscode-resource scheme. The filesystem path of the resource can be obtained from the path component of the webview URI.
|
||||
const path = Uri.parse(webviewUri).path;
|
||||
// For this path to be interpreted on the filesystem, we need to parse it as a filesystem URI for the current platform.
|
||||
return Uri.file(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the specified CodeQL location to a URI into the source archive.
|
||||
* @param loc CodeQL location to resolve. Must have a non-empty value for `loc.file`.
|
||||
* @param databaseItem Database in which to resolve the file location.
|
||||
*/
|
||||
export function resolveFivePartLocation(
|
||||
loc: FivePartLocation,
|
||||
databaseItem: DatabaseItem
|
||||
): Location {
|
||||
// `Range` is a half-open interval, and is zero-based. CodeQL locations are closed intervals, and
|
||||
// are one-based. Adjust accordingly.
|
||||
const range = new Range(
|
||||
Math.max(0, loc.lineStart - 1),
|
||||
Math.max(0, loc.colStart - 1),
|
||||
Math.max(0, loc.lineEnd - 1),
|
||||
Math.max(0, loc.colEnd)
|
||||
);
|
||||
|
||||
return new Location(databaseItem.resolveSourceFile(loc.file), range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the specified CodeQL filesystem resource location to a URI into the source archive.
|
||||
* @param loc CodeQL location to resolve, corresponding to an entire filesystem resource. Must have a non-empty value for `loc.file`.
|
||||
* @param databaseItem Database in which to resolve the filesystem resource location.
|
||||
*/
|
||||
export function resolveWholeFileLocation(
|
||||
loc: WholeFileLocation,
|
||||
databaseItem: DatabaseItem
|
||||
): Location {
|
||||
// A location corresponding to the start of the file.
|
||||
const range = new Range(0, 0, 0, 0);
|
||||
return new Location(databaseItem.resolveSourceFile(loc.file), range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve the specified CodeQL location to a URI into the source archive. If no exact location
|
||||
* can be resolved, returns `undefined`.
|
||||
* @param loc CodeQL location to resolve
|
||||
* @param databaseItem Database in which to resolve the file location.
|
||||
*/
|
||||
export function tryResolveLocation(
|
||||
loc: LocationValue | undefined,
|
||||
databaseItem: DatabaseItem
|
||||
): Location | undefined {
|
||||
const resolvableLoc = tryGetResolvableLocation(loc);
|
||||
if (resolvableLoc === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
switch (resolvableLoc.t) {
|
||||
case LocationStyle.FivePart:
|
||||
return resolveFivePartLocation(resolvableLoc, databaseItem);
|
||||
case LocationStyle.WholeFile:
|
||||
return resolveWholeFileLocation(resolvableLoc, databaseItem);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTML to populate the given webview.
|
||||
* Uses a content security policy that only loads the given script.
|
||||
*/
|
||||
export function getHtmlForWebview(
|
||||
webview: Webview,
|
||||
scriptUriOnDisk: Uri,
|
||||
stylesheetUriOnDisk: Uri
|
||||
): string {
|
||||
// Convert the on-disk URIs into webview URIs.
|
||||
const scriptWebviewUri = webview.asWebviewUri(scriptUriOnDisk);
|
||||
const stylesheetWebviewUri = webview.asWebviewUri(stylesheetUriOnDisk);
|
||||
// Use a nonce in the content security policy to uniquely identify the above resources.
|
||||
const nonce = getNonce();
|
||||
/*
|
||||
* Content security policy:
|
||||
* default-src: allow nothing by default.
|
||||
* script-src: allow only the given script, using the nonce.
|
||||
* style-src: allow only the given stylesheet, using the nonce.
|
||||
* connect-src: only allow fetch calls to webview resource URIs
|
||||
* (this is used to load BQRS result files).
|
||||
*/
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src ${webview.cspSource};">
|
||||
<link nonce="${nonce}" rel="stylesheet" href="${stylesheetWebviewUri}">
|
||||
</head>
|
||||
<body>
|
||||
<div id=root>
|
||||
</div>
|
||||
<script nonce="${nonce}" src="${scriptWebviewUri}">
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function showLocation(
|
||||
loc: ResolvableLocationValue,
|
||||
databaseItem: DatabaseItem
|
||||
): Promise<void> {
|
||||
const resolvedLocation = tryResolveLocation(loc, databaseItem);
|
||||
if (resolvedLocation) {
|
||||
const doc = await workspace.openTextDocument(resolvedLocation.uri);
|
||||
const editorsWithDoc = Window.visibleTextEditors.filter(
|
||||
(e) => e.document === doc
|
||||
);
|
||||
const editor =
|
||||
editorsWithDoc.length > 0
|
||||
? editorsWithDoc[0]
|
||||
: await Window.showTextDocument(doc, ViewColumn.One);
|
||||
const range = resolvedLocation.range;
|
||||
// When highlighting the range, vscode's occurrence-match and bracket-match highlighting will
|
||||
// trigger based on where we place the cursor/selection, and will compete for the user's attention.
|
||||
// For reference:
|
||||
// - Occurences are highlighted when the cursor is next to or inside a word or a whole word is selected.
|
||||
// - Brackets are highlighted when the cursor is next to a bracket and there is an empty selection.
|
||||
// - Multi-line selections explicitly highlight line-break characters, but multi-line decorators do not.
|
||||
//
|
||||
// For single-line ranges, select the whole range, mainly to disable bracket highlighting.
|
||||
// For multi-line ranges, place the cursor at the beginning to avoid visual artifacts from selected line-breaks.
|
||||
// Multi-line ranges are usually large enough to overshadow the noise from bracket highlighting.
|
||||
const selectionEnd =
|
||||
range.start.line === range.end.line ? range.end : range.start;
|
||||
editor.selection = new Selection(range.start, selectionEnd);
|
||||
editor.revealRange(range, TextEditorRevealType.InCenter);
|
||||
editor.setDecorations(shownLocationDecoration, [range]);
|
||||
editor.setDecorations(shownLocationLineDecoration, [range]);
|
||||
}
|
||||
}
|
||||
|
||||
const findMatchBackground = new ThemeColor("editor.findMatchBackground");
|
||||
const findRangeHighlightBackground = new ThemeColor(
|
||||
"editor.findRangeHighlightBackground"
|
||||
);
|
||||
|
||||
export const shownLocationDecoration = Window.createTextEditorDecorationType({
|
||||
backgroundColor: findMatchBackground,
|
||||
});
|
||||
|
||||
export const shownLocationLineDecoration = Window.createTextEditorDecorationType(
|
||||
{
|
||||
backgroundColor: findRangeHighlightBackground,
|
||||
isWholeLine: true,
|
||||
}
|
||||
);
|
||||
|
||||
export async function jumpToLocation(
|
||||
msg: ViewSourceFileMsg,
|
||||
databaseManager: DatabaseManager,
|
||||
logger: Logger
|
||||
) {
|
||||
const databaseItem = databaseManager.findDatabaseItem(
|
||||
Uri.parse(msg.databaseUri)
|
||||
);
|
||||
if (databaseItem !== undefined) {
|
||||
try {
|
||||
await showLocation(msg.loc, databaseItem);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
if (e.message.match(/File not found/)) {
|
||||
Window.showErrorMessage(
|
||||
`Original file of this result is not in the database's source archive.`
|
||||
);
|
||||
} else {
|
||||
logger.log(`Unable to handleMsgFromView: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
logger.log(`Unable to handleMsgFromView: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user