Skip to content

Commit b803a80

Browse files
committed
Add unit tests for interface-utils.ts
Also, some moving around of functions and whitespace changes.
1 parent fceea64 commit b803a80

File tree

13 files changed

+504
-320
lines changed

13 files changed

+504
-320
lines changed

extensions/ql-vscode/src/compare/compare-interface.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ import {
1818
import { Logger } from "../logging";
1919
import { CodeQLCliServer } from "../cli";
2020
import { DatabaseManager } from "../databases";
21-
import {
22-
getHtmlForWebview,
23-
jumpToLocation,
24-
} from "../webview-utils";
21+
import { getHtmlForWebview, jumpToLocation } from "../interface-utils";
2522
import { adaptSchema, adaptBqrs, RawResultSet } from "../adapt";
2623
import { BQRSInfo } from "../bqrs-cli-types";
2724
import resultsDiff from "./resultsDiff";
@@ -190,7 +187,8 @@ export class CompareInterfaceManager extends DisposableObject {
190187
const commonResultSetNames = fromSchemaNames.filter((name) =>
191188
toSchemaNames.includes(name)
192189
);
193-
const currentResultSetName = selectedResultSetName || commonResultSetNames[0];
190+
const currentResultSetName =
191+
selectedResultSetName || commonResultSetNames[0];
194192
const fromResultSet = await this.getResultSet(
195193
fromSchemas,
196194
currentResultSetName,
@@ -213,7 +211,11 @@ export class CompareInterfaceManager extends DisposableObject {
213211
if (!this.comparePair?.from || !this.comparePair.to) {
214212
return;
215213
}
216-
await this.showResults(this.comparePair.from, this.comparePair.to, newResultSetName);
214+
await this.showResults(
215+
this.comparePair.from,
216+
this.comparePair.to,
217+
newResultSetName
218+
);
217219
}
218220

219221
private async getResultSet(

extensions/ql-vscode/src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import * as helpers from './helpers';
2222
import { assertNever } from './helpers-pure';
2323
import { spawnIdeServer } from './ide-server';
2424
import { InterfaceManager } from './interface';
25-
import { WebviewReveal } from './webview-utils';
25+
import { WebviewReveal } from './interface-utils';
2626
import { ideServerLogger, logger, queryServerLogger } from './logging';
2727
import { QueryHistoryManager } from './query-history';
2828
import { CompletedQuery } from './query-results';

extensions/ql-vscode/src/interface-types.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
import * as sarif from 'sarif';
2-
import { ResolvableLocationValue, ColumnSchema } from 'semmle-bqrs';
3-
import { ResultRow, ParsedResultSets } from './adapt';
2+
import {
3+
ResolvableLocationValue,
4+
ColumnSchema,
5+
ResultSetSchema,
6+
} from "semmle-bqrs";
7+
import { ResultRow, ParsedResultSets, RawResultSet } from './adapt';
8+
9+
/**
10+
* This module contains types and code that are shared between
11+
* the webview and the extension.
12+
*/
13+
14+
export const SELECT_TABLE_NAME = "#select";
15+
export const ALERTS_TABLE_NAME = "alerts";
16+
17+
export type RawTableResultSet = { t: "RawResultSet" } & RawResultSet;
18+
export type PathTableResultSet = {
19+
t: "SarifResultSet";
20+
readonly schema: ResultSetSchema;
21+
name: string;
22+
} & Interpretation;
23+
24+
export type ResultSet = RawTableResultSet | PathTableResultSet;
425

526
/**
627
* Only ever show this many results per run in interpreted results.
@@ -228,3 +249,26 @@ export type QueryCompareResult = {
228249
from: ResultRow[];
229250
to: ResultRow[];
230251
};
252+
253+
/**
254+
* Extract the name of the default result. Prefer returning
255+
* 'alerts', or '#select'. Otherwise return the first in the list.
256+
*
257+
* Note that this is the only function in this module. It must be
258+
* placed here since it is shared across the webview boundary.
259+
*
260+
* We should consider moving to a separate module to ensure this
261+
* one is types only.
262+
*
263+
* @param resultSetNames
264+
*/
265+
export function getDefaultResultSetName(
266+
resultSetNames: readonly string[]
267+
): string {
268+
// Choose first available result set from the array
269+
return [
270+
ALERTS_TABLE_NAME,
271+
SELECT_TABLE_NAME,
272+
resultSetNames[0],
273+
].filter((resultSetName) => resultSetNames.includes(resultSetName))[0];
274+
}
Lines changed: 224 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,233 @@
1-
import { RawResultSet } from './adapt';
2-
import { ResultSetSchema } from 'semmle-bqrs';
3-
import { Interpretation } from './interface-types';
1+
import * as crypto from "crypto";
2+
import {
3+
Uri,
4+
Location,
5+
Range,
6+
WebviewPanel,
7+
Webview,
8+
workspace,
9+
window as Window,
10+
ViewColumn,
11+
Selection,
12+
TextEditorRevealType,
13+
ThemeColor,
14+
} from "vscode";
15+
import {
16+
FivePartLocation,
17+
LocationStyle,
18+
LocationValue,
19+
tryGetResolvableLocation,
20+
WholeFileLocation,
21+
ResolvableLocationValue,
22+
} from "semmle-bqrs";
23+
import { DatabaseItem, DatabaseManager } from "./databases";
24+
import { ViewSourceFileMsg } from "./interface-types";
25+
import { Logger } from "./logging";
426

5-
export const SELECT_TABLE_NAME = "#select";
6-
export const ALERTS_TABLE_NAME = "alerts";
27+
/**
28+
* This module contains functions and types that are sharedd between
29+
* interface.ts and compare-interface.ts.
30+
*/
731

8-
export type RawTableResultSet = { t: "RawResultSet" } & RawResultSet;
9-
export type PathTableResultSet = {
10-
t: "SarifResultSet";
11-
readonly schema: ResultSetSchema;
12-
name: string;
13-
} & Interpretation;
32+
/** Gets a nonce string created with 128 bits of entropy. */
33+
export function getNonce(): string {
34+
return crypto.randomBytes(16).toString("base64");
35+
}
36+
37+
/**
38+
* Whether to force webview to reveal
39+
*/
40+
export enum WebviewReveal {
41+
Forced,
42+
NotForced,
43+
}
44+
45+
/** Converts a filesystem URI into a webview URI string that the given panel can use to read the file. */
46+
export function fileUriToWebviewUri(
47+
panel: WebviewPanel,
48+
fileUriOnDisk: Uri
49+
): string {
50+
return panel.webview.asWebviewUri(fileUriOnDisk).toString();
51+
}
1452

15-
export type ResultSet = RawTableResultSet | PathTableResultSet;
53+
/** Converts a URI string received from a webview into a local filesystem URI for the same resource. */
54+
export function webviewUriToFileUri(webviewUri: string): Uri {
55+
// Webview URIs used the vscode-resource scheme. The filesystem path of the resource can be obtained from the path component of the webview URI.
56+
const path = Uri.parse(webviewUri).path;
57+
// For this path to be interpreted on the filesystem, we need to parse it as a filesystem URI for the current platform.
58+
return Uri.file(path);
59+
}
1660

17-
export function getDefaultResultSet(resultSets: readonly ResultSet[]): string {
18-
return getDefaultResultSetName(
19-
resultSets.map((resultSet) => resultSet.schema.name)
61+
/**
62+
* Resolves the specified CodeQL location to a URI into the source archive.
63+
* @param loc CodeQL location to resolve. Must have a non-empty value for `loc.file`.
64+
* @param databaseItem Database in which to resolve the file location.
65+
*/
66+
function resolveFivePartLocation(
67+
loc: FivePartLocation,
68+
databaseItem: DatabaseItem
69+
): Location {
70+
// `Range` is a half-open interval, and is zero-based. CodeQL locations are closed intervals, and
71+
// are one-based. Adjust accordingly.
72+
const range = new Range(
73+
Math.max(0, loc.lineStart - 1),
74+
Math.max(0, loc.colStart - 1),
75+
Math.max(0, loc.lineEnd - 1),
76+
Math.max(0, loc.colEnd)
2077
);
78+
79+
return new Location(databaseItem.resolveSourceFile(loc.file), range);
80+
}
81+
82+
/**
83+
* Resolves the specified CodeQL filesystem resource location to a URI into the source archive.
84+
* @param loc CodeQL location to resolve, corresponding to an entire filesystem resource. Must have a non-empty value for `loc.file`.
85+
* @param databaseItem Database in which to resolve the filesystem resource location.
86+
*/
87+
function resolveWholeFileLocation(
88+
loc: WholeFileLocation,
89+
databaseItem: DatabaseItem
90+
): Location {
91+
// A location corresponding to the start of the file.
92+
const range = new Range(0, 0, 0, 0);
93+
return new Location(databaseItem.resolveSourceFile(loc.file), range);
2194
}
2295

23-
export function getDefaultResultSetName(
24-
resultSetNames: readonly string[]
96+
/**
97+
* Try to resolve the specified CodeQL location to a URI into the source archive. If no exact location
98+
* can be resolved, returns `undefined`.
99+
* @param loc CodeQL location to resolve
100+
* @param databaseItem Database in which to resolve the file location.
101+
*/
102+
export function tryResolveLocation(
103+
loc: LocationValue | undefined,
104+
databaseItem: DatabaseItem
105+
): Location | undefined {
106+
const resolvableLoc = tryGetResolvableLocation(loc);
107+
if (resolvableLoc === undefined) {
108+
return undefined;
109+
}
110+
switch (resolvableLoc.t) {
111+
case LocationStyle.FivePart:
112+
return resolveFivePartLocation(resolvableLoc, databaseItem);
113+
case LocationStyle.WholeFile:
114+
return resolveWholeFileLocation(resolvableLoc, databaseItem);
115+
default:
116+
return undefined;
117+
}
118+
}
119+
120+
/**
121+
* Returns HTML to populate the given webview.
122+
* Uses a content security policy that only loads the given script.
123+
*/
124+
export function getHtmlForWebview(
125+
webview: Webview,
126+
scriptUriOnDisk: Uri,
127+
stylesheetUriOnDisk: Uri
25128
): string {
26-
// Choose first available result set from the array
27-
return [
28-
ALERTS_TABLE_NAME,
29-
SELECT_TABLE_NAME,
30-
resultSetNames[0],
31-
].filter((resultSetName) => resultSetNames.includes(resultSetName))[0];
129+
// Convert the on-disk URIs into webview URIs.
130+
const scriptWebviewUri = webview.asWebviewUri(scriptUriOnDisk);
131+
const stylesheetWebviewUri = webview.asWebviewUri(stylesheetUriOnDisk);
132+
// Use a nonce in the content security policy to uniquely identify the above resources.
133+
const nonce = getNonce();
134+
/*
135+
* Content security policy:
136+
* default-src: allow nothing by default.
137+
* script-src: allow only the given script, using the nonce.
138+
* style-src: allow only the given stylesheet, using the nonce.
139+
* connect-src: only allow fetch calls to webview resource URIs
140+
* (this is used to load BQRS result files).
141+
*/
142+
return `
143+
<html>
144+
<head>
145+
<meta http-equiv="Content-Security-Policy"
146+
content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src ${webview.cspSource};">
147+
<link nonce="${nonce}" rel="stylesheet" href="${stylesheetWebviewUri}">
148+
</head>
149+
<body>
150+
<div id=root>
151+
</div>
152+
<script nonce="${nonce}" src="${scriptWebviewUri}">
153+
</script>
154+
</body>
155+
</html>`;
156+
}
157+
158+
export async function showLocation(
159+
loc: ResolvableLocationValue,
160+
databaseItem: DatabaseItem
161+
): Promise<void> {
162+
const resolvedLocation = tryResolveLocation(loc, databaseItem);
163+
if (resolvedLocation) {
164+
const doc = await workspace.openTextDocument(resolvedLocation.uri);
165+
const editorsWithDoc = Window.visibleTextEditors.filter(
166+
(e) => e.document === doc
167+
);
168+
const editor =
169+
editorsWithDoc.length > 0
170+
? editorsWithDoc[0]
171+
: await Window.showTextDocument(doc, ViewColumn.One);
172+
const range = resolvedLocation.range;
173+
// When highlighting the range, vscode's occurrence-match and bracket-match highlighting will
174+
// trigger based on where we place the cursor/selection, and will compete for the user's attention.
175+
// For reference:
176+
// - Occurences are highlighted when the cursor is next to or inside a word or a whole word is selected.
177+
// - Brackets are highlighted when the cursor is next to a bracket and there is an empty selection.
178+
// - Multi-line selections explicitly highlight line-break characters, but multi-line decorators do not.
179+
//
180+
// For single-line ranges, select the whole range, mainly to disable bracket highlighting.
181+
// For multi-line ranges, place the cursor at the beginning to avoid visual artifacts from selected line-breaks.
182+
// Multi-line ranges are usually large enough to overshadow the noise from bracket highlighting.
183+
const selectionEnd =
184+
range.start.line === range.end.line ? range.end : range.start;
185+
editor.selection = new Selection(range.start, selectionEnd);
186+
editor.revealRange(range, TextEditorRevealType.InCenter);
187+
editor.setDecorations(shownLocationDecoration, [range]);
188+
editor.setDecorations(shownLocationLineDecoration, [range]);
189+
}
190+
}
191+
192+
const findMatchBackground = new ThemeColor("editor.findMatchBackground");
193+
const findRangeHighlightBackground = new ThemeColor(
194+
"editor.findRangeHighlightBackground"
195+
);
196+
197+
export const shownLocationDecoration = Window.createTextEditorDecorationType({
198+
backgroundColor: findMatchBackground,
199+
});
200+
201+
export const shownLocationLineDecoration = Window.createTextEditorDecorationType(
202+
{
203+
backgroundColor: findRangeHighlightBackground,
204+
isWholeLine: true,
205+
}
206+
);
207+
208+
export async function jumpToLocation(
209+
msg: ViewSourceFileMsg,
210+
databaseManager: DatabaseManager,
211+
logger: Logger
212+
) {
213+
const databaseItem = databaseManager.findDatabaseItem(
214+
Uri.parse(msg.databaseUri)
215+
);
216+
if (databaseItem !== undefined) {
217+
try {
218+
await showLocation(msg.loc, databaseItem);
219+
} catch (e) {
220+
if (e instanceof Error) {
221+
if (e.message.match(/File not found/)) {
222+
Window.showErrorMessage(
223+
`Original file of this result is not in the database's source archive.`
224+
);
225+
} else {
226+
logger.log(`Unable to handleMsgFromView: ${e.message}`);
227+
}
228+
} else {
229+
logger.log(`Unable to handleMsgFromView: ${e}`);
230+
}
231+
}
232+
}
32233
}

extensions/ql-vscode/src/interface.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ import {
4949
shownLocationDecoration,
5050
shownLocationLineDecoration,
5151
jumpToLocation,
52-
} from "./webview-utils";
53-
import { getDefaultResultSetName } from "./interface-utils";
52+
} from "./interface-utils";
53+
import { getDefaultResultSetName } from "./interface-types";
5454

5555
/**
5656
* interface.ts

extensions/ql-vscode/src/view/alert-table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { LocationStyle } from 'semmle-bqrs';
66
import * as octicons from './octicons';
77
import { className, renderLocation, ResultTableProps, zebraStripe, selectableZebraStripe, jumpToLocation, nextSortDirection } from './result-table-utils';
88
import { onNavigation, NavigationEvent } from './results';
9-
import { PathTableResultSet } from '../interface-utils';
9+
import { PathTableResultSet } from '../interface-types';
1010
import { parseSarifPlainTextMessage, parseSarifLocation } from '../sarif-utils';
1111
import { InterpretedResultsSortColumn, SortDirection, InterpretedResultsSortState } from '../interface-types';
1212
import { vscode } from './vscode-api';

extensions/ql-vscode/src/view/raw-results-table.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import * as React from "react";
22
import { ResultTableProps, className } from "./result-table-utils";
33
import { RAW_RESULTS_LIMIT, RawResultsSortState } from "../interface-types";
4-
import { RawTableResultSet } from "../interface-utils";
4+
import { RawTableResultSet } from "../interface-types";
55
import RawTableHeader from "./RawTableHeader";
66
import RawTableRow from "./RawTableRow";
7+
import { ResultRow } from "../adapt";
78

89
export type RawTableProps = ResultTableProps & {
910
resultSet: RawTableResultSet;
@@ -26,7 +27,7 @@ export class RawTable extends React.Component<RawTableProps, {}> {
2627
dataRows = dataRows.slice(0, RAW_RESULTS_LIMIT);
2728
}
2829

29-
const tableRows = dataRows.map((row, rowIndex) =>
30+
const tableRows = dataRows.map((row: ResultRow, rowIndex: number) =>
3031
<RawTableRow
3132
key={rowIndex}
3233
rowIndex={rowIndex}

0 commit comments

Comments
 (0)