We're checking that the skeleton QL pack doesn't exist as a workspace folder, so we should be creating this folder in the workspace as well. Initially this was being created in VSCode's local storage.
1067 lines
32 KiB
TypeScript
1067 lines
32 KiB
TypeScript
import { pathExists, stat, remove } from "fs-extra";
|
|
import { promise as glob } from "glob-promise";
|
|
import { join, basename, resolve, relative, dirname, extname } from "path";
|
|
import * as vscode from "vscode";
|
|
import * as cli from "./cli";
|
|
import { ExtensionContext } from "vscode";
|
|
import {
|
|
showAndLogWarningMessage,
|
|
showAndLogInformationMessage,
|
|
isLikelyDatabaseRoot,
|
|
showAndLogExceptionWithTelemetry,
|
|
isFolderAlreadyInWorkspace,
|
|
showBinaryChoiceDialog,
|
|
} from "./helpers";
|
|
import { ProgressCallback, withProgress } from "./commandRunner";
|
|
import {
|
|
zipArchiveScheme,
|
|
encodeArchiveBasePath,
|
|
decodeSourceArchiveUri,
|
|
encodeSourceArchiveUri,
|
|
} from "./archive-filesystem-provider";
|
|
import { DisposableObject } from "./pure/disposable-object";
|
|
import { Logger, extLogger } from "./common";
|
|
import { asError, getErrorMessage } from "./pure/helpers-pure";
|
|
import { QueryRunner } from "./queryRunner";
|
|
import { pathsEqual } from "./pure/files";
|
|
import { redactableError } from "./pure/errors";
|
|
import { isCodespacesTemplate } from "./config";
|
|
import { QlPackGenerator, QueryLanguage } from "./qlpack-generator";
|
|
|
|
/**
|
|
* databases.ts
|
|
* ------------
|
|
* Managing state of what the current database is, and what other
|
|
* databases have been recently selected.
|
|
*
|
|
* The source of truth of the current state resides inside the
|
|
* `DatabaseManager` class below.
|
|
*/
|
|
|
|
/**
|
|
* The name of the key in the workspaceState dictionary in which we
|
|
* persist the current database across sessions.
|
|
*/
|
|
const CURRENT_DB = "currentDatabase";
|
|
|
|
/**
|
|
* The name of the key in the workspaceState dictionary in which we
|
|
* persist the list of databases across sessions.
|
|
*/
|
|
const DB_LIST = "databaseList";
|
|
|
|
export interface DatabaseOptions {
|
|
displayName?: string;
|
|
ignoreSourceArchive?: boolean;
|
|
dateAdded?: number | undefined;
|
|
language?: string;
|
|
}
|
|
|
|
export interface FullDatabaseOptions extends DatabaseOptions {
|
|
ignoreSourceArchive: boolean;
|
|
dateAdded: number | undefined;
|
|
language: string | undefined;
|
|
}
|
|
|
|
interface PersistedDatabaseItem {
|
|
uri: string;
|
|
options?: DatabaseOptions;
|
|
}
|
|
|
|
/**
|
|
* The layout of the database.
|
|
*/
|
|
export enum DatabaseKind {
|
|
/** A CodeQL database */
|
|
Database,
|
|
/** A raw QL dataset */
|
|
RawDataset,
|
|
}
|
|
|
|
export interface DatabaseContents {
|
|
/** The layout of the database */
|
|
kind: DatabaseKind;
|
|
/**
|
|
* The name of the database.
|
|
*/
|
|
name: string;
|
|
/** The URI of the QL dataset within the database. */
|
|
datasetUri: vscode.Uri;
|
|
/** The URI of the source archive within the database, if one exists. */
|
|
sourceArchiveUri?: vscode.Uri;
|
|
/** The URI of the CodeQL database scheme within the database, if exactly one exists. */
|
|
dbSchemeUri?: vscode.Uri;
|
|
}
|
|
|
|
/**
|
|
* An error thrown when we cannot find a valid database in a putative
|
|
* database directory.
|
|
*/
|
|
class InvalidDatabaseError extends Error {}
|
|
|
|
async function findDataset(parentDirectory: string): Promise<vscode.Uri> {
|
|
/*
|
|
* Look directly in the root
|
|
*/
|
|
let dbRelativePaths = await glob("db-*/", {
|
|
cwd: parentDirectory,
|
|
});
|
|
|
|
if (dbRelativePaths.length === 0) {
|
|
/*
|
|
* Check If they are in the old location
|
|
*/
|
|
dbRelativePaths = await glob("working/db-*/", {
|
|
cwd: parentDirectory,
|
|
});
|
|
}
|
|
if (dbRelativePaths.length === 0) {
|
|
throw new InvalidDatabaseError(
|
|
`'${parentDirectory}' does not contain a dataset directory.`,
|
|
);
|
|
}
|
|
|
|
const dbAbsolutePath = join(parentDirectory, dbRelativePaths[0]);
|
|
if (dbRelativePaths.length > 1) {
|
|
void showAndLogWarningMessage(
|
|
`Found multiple dataset directories in database, using '${dbAbsolutePath}'.`,
|
|
);
|
|
}
|
|
|
|
return vscode.Uri.file(dbAbsolutePath);
|
|
}
|
|
|
|
// exported for testing
|
|
export async function findSourceArchive(
|
|
databasePath: string,
|
|
): Promise<vscode.Uri | undefined> {
|
|
const relativePaths = ["src", "output/src_archive"];
|
|
|
|
for (const relativePath of relativePaths) {
|
|
const basePath = join(databasePath, relativePath);
|
|
const zipPath = `${basePath}.zip`;
|
|
|
|
// Prefer using a zip archive over a directory.
|
|
if (await pathExists(zipPath)) {
|
|
return encodeArchiveBasePath(zipPath);
|
|
} else if (await pathExists(basePath)) {
|
|
return vscode.Uri.file(basePath);
|
|
}
|
|
}
|
|
|
|
void showAndLogInformationMessage(
|
|
`Could not find source archive for database '${databasePath}'. Assuming paths are absolute.`,
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
/** Gets the relative paths of all `.dbscheme` files in the given directory. */
|
|
async function getDbSchemeFiles(dbDirectory: string): Promise<string[]> {
|
|
return await glob("*.dbscheme", { cwd: dbDirectory });
|
|
}
|
|
|
|
export class DatabaseResolver {
|
|
public static async resolveDatabaseContents(
|
|
uri: vscode.Uri,
|
|
): Promise<DatabaseContents> {
|
|
if (uri.scheme !== "file") {
|
|
throw new Error(
|
|
`Database URI scheme '${uri.scheme}' not supported; only 'file' URIs are supported.`,
|
|
);
|
|
}
|
|
const databasePath = uri.fsPath;
|
|
if (!(await pathExists(databasePath))) {
|
|
throw new InvalidDatabaseError(
|
|
`Database '${databasePath}' does not exist.`,
|
|
);
|
|
}
|
|
|
|
const contents = await this.resolveDatabase(databasePath);
|
|
|
|
if (contents === undefined) {
|
|
throw new InvalidDatabaseError(
|
|
`'${databasePath}' is not a valid database.`,
|
|
);
|
|
}
|
|
|
|
// Look for a single dbscheme file within the database.
|
|
// This should be found in the dataset directory, regardless of the form of database.
|
|
const dbPath = contents.datasetUri.fsPath;
|
|
const dbSchemeFiles = await getDbSchemeFiles(dbPath);
|
|
if (dbSchemeFiles.length === 0) {
|
|
throw new InvalidDatabaseError(
|
|
`Database '${databasePath}' does not contain a CodeQL dbscheme under '${dbPath}'.`,
|
|
);
|
|
} else if (dbSchemeFiles.length > 1) {
|
|
throw new InvalidDatabaseError(
|
|
`Database '${databasePath}' contains multiple CodeQL dbschemes under '${dbPath}'.`,
|
|
);
|
|
} else {
|
|
contents.dbSchemeUri = vscode.Uri.file(resolve(dbPath, dbSchemeFiles[0]));
|
|
}
|
|
return contents;
|
|
}
|
|
|
|
public static async resolveDatabase(
|
|
databasePath: string,
|
|
): Promise<DatabaseContents> {
|
|
const name = basename(databasePath);
|
|
|
|
// Look for dataset and source archive.
|
|
const datasetUri = await findDataset(databasePath);
|
|
const sourceArchiveUri = await findSourceArchive(databasePath);
|
|
|
|
return {
|
|
kind: DatabaseKind.Database,
|
|
name,
|
|
datasetUri,
|
|
sourceArchiveUri,
|
|
};
|
|
}
|
|
}
|
|
|
|
/** An item in the list of available databases */
|
|
export interface DatabaseItem {
|
|
/** The URI of the database */
|
|
readonly databaseUri: vscode.Uri;
|
|
/** The name of the database to be displayed in the UI */
|
|
name: string;
|
|
|
|
/** The primary language of the database or empty string if unknown */
|
|
readonly language: string;
|
|
/** The URI of the database's source archive, or `undefined` if no source archive is to be used. */
|
|
readonly sourceArchive: vscode.Uri | undefined;
|
|
/**
|
|
* The contents of the database.
|
|
* Will be `undefined` if the database is invalid. Can be updated by calling `refresh()`.
|
|
*/
|
|
readonly contents: DatabaseContents | undefined;
|
|
|
|
/**
|
|
* The date this database was added as a unix timestamp. Or undefined if we don't know.
|
|
*/
|
|
readonly dateAdded: number | undefined;
|
|
|
|
/** If the database is invalid, describes why. */
|
|
readonly error: Error | undefined;
|
|
/**
|
|
* Resolves the contents of the database.
|
|
*
|
|
* @remarks
|
|
* The contents include the database directory, source archive, and metadata about the database.
|
|
* If the database is invalid, `this.error` is updated with the error object that describes why
|
|
* the database is invalid. This error is also thrown.
|
|
*/
|
|
refresh(): Promise<void>;
|
|
/**
|
|
* Resolves a filename to its URI in the source archive.
|
|
*
|
|
* @param file Filename within the source archive. May be `undefined` to return a dummy file path.
|
|
*/
|
|
resolveSourceFile(file: string | undefined): vscode.Uri;
|
|
|
|
/**
|
|
* Holds if the database item has a `.dbinfo` or `codeql-database.yml` file.
|
|
*/
|
|
hasMetadataFile(): Promise<boolean>;
|
|
|
|
/**
|
|
* Returns `sourceLocationPrefix` of exported database.
|
|
*/
|
|
getSourceLocationPrefix(server: cli.CodeQLCliServer): Promise<string>;
|
|
|
|
/**
|
|
* Returns dataset folder of exported database.
|
|
*/
|
|
getDatasetFolder(server: cli.CodeQLCliServer): Promise<string>;
|
|
|
|
/**
|
|
* Returns the root uri of the virtual filesystem for this database's source archive,
|
|
* as displayed in the filesystem explorer.
|
|
*/
|
|
getSourceArchiveExplorerUri(): vscode.Uri;
|
|
|
|
/**
|
|
* Holds if `uri` belongs to this database's source archive.
|
|
*/
|
|
belongsToSourceArchiveExplorerUri(uri: vscode.Uri): boolean;
|
|
|
|
/**
|
|
* Whether the database may be affected by test execution for the given path.
|
|
*/
|
|
isAffectedByTest(testPath: string): Promise<boolean>;
|
|
|
|
/**
|
|
* Gets the state of this database, to be persisted in the workspace state.
|
|
*/
|
|
getPersistedState(): PersistedDatabaseItem;
|
|
|
|
/**
|
|
* Verifies that this database item has a zipped source folder. Returns an error message if it does not.
|
|
*/
|
|
verifyZippedSources(): string | undefined;
|
|
}
|
|
|
|
export enum DatabaseEventKind {
|
|
Add = "Add",
|
|
Remove = "Remove",
|
|
|
|
// Fired when databases are refreshed from persisted state
|
|
Refresh = "Refresh",
|
|
|
|
// Fired when the current database changes
|
|
Change = "Change",
|
|
|
|
Rename = "Rename",
|
|
}
|
|
|
|
export interface DatabaseChangedEvent {
|
|
kind: DatabaseEventKind;
|
|
item: DatabaseItem | undefined;
|
|
}
|
|
|
|
// Exported for testing
|
|
export class DatabaseItemImpl implements DatabaseItem {
|
|
private _error: Error | undefined = undefined;
|
|
private _contents: DatabaseContents | undefined;
|
|
/** A cache of database info */
|
|
private _dbinfo: cli.DbInfo | undefined;
|
|
|
|
public constructor(
|
|
public readonly databaseUri: vscode.Uri,
|
|
contents: DatabaseContents | undefined,
|
|
private options: FullDatabaseOptions,
|
|
private readonly onChanged: (event: DatabaseChangedEvent) => void,
|
|
) {
|
|
this._contents = contents;
|
|
}
|
|
|
|
public get name(): string {
|
|
if (this.options.displayName) {
|
|
return this.options.displayName;
|
|
} else if (this._contents) {
|
|
return this._contents.name;
|
|
} else {
|
|
return basename(this.databaseUri.fsPath);
|
|
}
|
|
}
|
|
|
|
public set name(newName: string) {
|
|
this.options.displayName = newName;
|
|
}
|
|
|
|
public get sourceArchive(): vscode.Uri | undefined {
|
|
if (this.options.ignoreSourceArchive || this._contents === undefined) {
|
|
return undefined;
|
|
} else {
|
|
return this._contents.sourceArchiveUri;
|
|
}
|
|
}
|
|
|
|
public get contents(): DatabaseContents | undefined {
|
|
return this._contents;
|
|
}
|
|
|
|
public get dateAdded(): number | undefined {
|
|
return this.options.dateAdded;
|
|
}
|
|
|
|
public get error(): Error | undefined {
|
|
return this._error;
|
|
}
|
|
|
|
public async refresh(): Promise<void> {
|
|
try {
|
|
try {
|
|
this._contents = await DatabaseResolver.resolveDatabaseContents(
|
|
this.databaseUri,
|
|
);
|
|
this._error = undefined;
|
|
} catch (e) {
|
|
this._contents = undefined;
|
|
this._error = asError(e);
|
|
throw e;
|
|
}
|
|
} finally {
|
|
this.onChanged({
|
|
kind: DatabaseEventKind.Refresh,
|
|
item: this,
|
|
});
|
|
}
|
|
}
|
|
|
|
public resolveSourceFile(uriStr: string | undefined): vscode.Uri {
|
|
const sourceArchive = this.sourceArchive;
|
|
const uri = uriStr ? vscode.Uri.parse(uriStr, true) : undefined;
|
|
if (uri && uri.scheme !== "file") {
|
|
throw new Error(
|
|
`Invalid uri scheme in ${uriStr}. Only 'file' is allowed.`,
|
|
);
|
|
}
|
|
if (!sourceArchive) {
|
|
if (uri) {
|
|
return uri;
|
|
} else {
|
|
return this.databaseUri;
|
|
}
|
|
}
|
|
|
|
if (uri) {
|
|
const relativeFilePath = decodeURI(uri.path)
|
|
.replace(":", "_")
|
|
.replace(/^\/*/, "");
|
|
if (sourceArchive.scheme === zipArchiveScheme) {
|
|
const zipRef = decodeSourceArchiveUri(sourceArchive);
|
|
const pathWithinSourceArchive =
|
|
zipRef.pathWithinSourceArchive === "/"
|
|
? relativeFilePath
|
|
: `${zipRef.pathWithinSourceArchive}/${relativeFilePath}`;
|
|
return encodeSourceArchiveUri({
|
|
pathWithinSourceArchive,
|
|
sourceArchiveZipPath: zipRef.sourceArchiveZipPath,
|
|
});
|
|
} else {
|
|
let newPath = sourceArchive.path;
|
|
if (!newPath.endsWith("/")) {
|
|
// Ensure a trailing slash.
|
|
newPath += "/";
|
|
}
|
|
newPath += relativeFilePath;
|
|
|
|
return sourceArchive.with({ path: newPath });
|
|
}
|
|
} else {
|
|
return sourceArchive;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the state of this database, to be persisted in the workspace state.
|
|
*/
|
|
public getPersistedState(): PersistedDatabaseItem {
|
|
return {
|
|
uri: this.databaseUri.toString(true),
|
|
options: this.options,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Holds if the database item refers to an exported snapshot
|
|
*/
|
|
public async hasMetadataFile(): Promise<boolean> {
|
|
return await isLikelyDatabaseRoot(this.databaseUri.fsPath);
|
|
}
|
|
|
|
/**
|
|
* Returns information about a database.
|
|
*/
|
|
private async getDbInfo(server: cli.CodeQLCliServer): Promise<cli.DbInfo> {
|
|
if (this._dbinfo === undefined) {
|
|
this._dbinfo = await server.resolveDatabase(this.databaseUri.fsPath);
|
|
}
|
|
return this._dbinfo;
|
|
}
|
|
|
|
/**
|
|
* Returns `sourceLocationPrefix` of database. Requires that the database
|
|
* has a `.dbinfo` file, which is the source of the prefix.
|
|
*/
|
|
public async getSourceLocationPrefix(
|
|
server: cli.CodeQLCliServer,
|
|
): Promise<string> {
|
|
const dbInfo = await this.getDbInfo(server);
|
|
return dbInfo.sourceLocationPrefix;
|
|
}
|
|
|
|
/**
|
|
* Returns path to dataset folder of database.
|
|
*/
|
|
public async getDatasetFolder(server: cli.CodeQLCliServer): Promise<string> {
|
|
const dbInfo = await this.getDbInfo(server);
|
|
return dbInfo.datasetFolder;
|
|
}
|
|
|
|
public get language() {
|
|
return this.options.language || "";
|
|
}
|
|
|
|
/**
|
|
* Returns the root uri of the virtual filesystem for this database's source archive.
|
|
*/
|
|
public getSourceArchiveExplorerUri(): vscode.Uri {
|
|
const sourceArchive = this.sourceArchive;
|
|
if (sourceArchive === undefined || !sourceArchive.fsPath.endsWith(".zip")) {
|
|
throw new Error(this.verifyZippedSources());
|
|
}
|
|
return encodeArchiveBasePath(sourceArchive.fsPath);
|
|
}
|
|
|
|
public verifyZippedSources(): string | undefined {
|
|
const sourceArchive = this.sourceArchive;
|
|
if (sourceArchive === undefined) {
|
|
return `${this.name} has no source archive.`;
|
|
}
|
|
|
|
if (!sourceArchive.fsPath.endsWith(".zip")) {
|
|
return `${this.name} has a source folder that is unzipped.`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Holds if `uri` belongs to this database's source archive.
|
|
*/
|
|
public belongsToSourceArchiveExplorerUri(uri: vscode.Uri): boolean {
|
|
if (this.sourceArchive === undefined) return false;
|
|
return (
|
|
uri.scheme === zipArchiveScheme &&
|
|
decodeSourceArchiveUri(uri).sourceArchiveZipPath ===
|
|
this.sourceArchive.fsPath
|
|
);
|
|
}
|
|
|
|
public async isAffectedByTest(testPath: string): Promise<boolean> {
|
|
const databasePath = this.databaseUri.fsPath;
|
|
if (!databasePath.endsWith(".testproj")) {
|
|
return false;
|
|
}
|
|
try {
|
|
const stats = await stat(testPath);
|
|
if (stats.isDirectory()) {
|
|
return !relative(testPath, databasePath).startsWith("..");
|
|
} else {
|
|
// database for /one/two/three/test.ql is at /one/two/three/three.testproj
|
|
const testdir = dirname(testPath);
|
|
const testdirbase = basename(testdir);
|
|
return pathsEqual(
|
|
databasePath,
|
|
join(testdir, `${testdirbase}.testproj`),
|
|
process.platform,
|
|
);
|
|
}
|
|
} catch {
|
|
// No information available for test path - assume database is unaffected.
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A promise that resolves to an event's result value when the event
|
|
* `event` fires. If waiting for the event takes too long (by default
|
|
* >1000ms) log a warning, and resolve to undefined.
|
|
*/
|
|
function eventFired<T>(
|
|
event: vscode.Event<T>,
|
|
timeoutMs = 1000,
|
|
): Promise<T | undefined> {
|
|
return new Promise((res, _rej) => {
|
|
const timeout = setTimeout(() => {
|
|
void extLogger.log(
|
|
`Waiting for event ${event} timed out after ${timeoutMs}ms`,
|
|
);
|
|
res(undefined);
|
|
dispose();
|
|
}, timeoutMs);
|
|
const disposable = event((e) => {
|
|
res(e);
|
|
dispose();
|
|
});
|
|
function dispose() {
|
|
clearTimeout(timeout);
|
|
disposable.dispose();
|
|
}
|
|
});
|
|
}
|
|
|
|
export class DatabaseManager extends DisposableObject {
|
|
private readonly _onDidChangeDatabaseItem = this.push(
|
|
new vscode.EventEmitter<DatabaseChangedEvent>(),
|
|
);
|
|
|
|
readonly onDidChangeDatabaseItem = this._onDidChangeDatabaseItem.event;
|
|
|
|
private readonly _onDidChangeCurrentDatabaseItem = this.push(
|
|
new vscode.EventEmitter<DatabaseChangedEvent>(),
|
|
);
|
|
readonly onDidChangeCurrentDatabaseItem =
|
|
this._onDidChangeCurrentDatabaseItem.event;
|
|
|
|
private readonly _databaseItems: DatabaseItem[] = [];
|
|
private _currentDatabaseItem: DatabaseItem | undefined = undefined;
|
|
|
|
constructor(
|
|
private readonly ctx: ExtensionContext,
|
|
private readonly qs: QueryRunner,
|
|
private readonly cli: cli.CodeQLCliServer,
|
|
public logger: Logger,
|
|
) {
|
|
super();
|
|
|
|
qs.onStart(this.reregisterDatabases.bind(this));
|
|
}
|
|
|
|
public async openDatabase(
|
|
progress: ProgressCallback,
|
|
token: vscode.CancellationToken,
|
|
uri: vscode.Uri,
|
|
displayName?: string,
|
|
): Promise<DatabaseItem> {
|
|
const contents = await DatabaseResolver.resolveDatabaseContents(uri);
|
|
// Ignore the source archive for QLTest databases by default.
|
|
const isQLTestDatabase = extname(uri.fsPath) === ".testproj";
|
|
const fullOptions: FullDatabaseOptions = {
|
|
ignoreSourceArchive: isQLTestDatabase,
|
|
// If a displayName is not passed in, the basename of folder containing the database is used.
|
|
displayName,
|
|
dateAdded: Date.now(),
|
|
language: await this.getPrimaryLanguage(uri.fsPath),
|
|
};
|
|
const databaseItem = new DatabaseItemImpl(
|
|
uri,
|
|
contents,
|
|
fullOptions,
|
|
(event) => {
|
|
this._onDidChangeDatabaseItem.fire(event);
|
|
},
|
|
);
|
|
|
|
await this.addDatabaseItem(progress, token, databaseItem);
|
|
await this.addDatabaseSourceArchiveFolder(databaseItem);
|
|
|
|
if (isCodespacesTemplate()) {
|
|
await this.createSkeletonPacks(databaseItem);
|
|
}
|
|
|
|
return databaseItem;
|
|
}
|
|
|
|
public async createSkeletonPacks(databaseItem: DatabaseItem) {
|
|
if (databaseItem === undefined) {
|
|
void this.logger.log(
|
|
"Could not create QL pack because no database is selected. Please add a database.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (databaseItem.language === "") {
|
|
void this.logger.log(
|
|
"Could not create skeleton QL pack because the selected database's language is not set.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const folderName = `codeql-custom-queries-${databaseItem.language}`;
|
|
if (isFolderAlreadyInWorkspace(folderName)) {
|
|
return;
|
|
}
|
|
|
|
const answer = await showBinaryChoiceDialog(
|
|
`We've noticed you don't have a CodeQL pack available to analyze this database. Can we set up a query pack for you?`,
|
|
);
|
|
|
|
if (!answer) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const qlPackGenerator = new QlPackGenerator(
|
|
folderName,
|
|
databaseItem.language as QueryLanguage,
|
|
this.cli,
|
|
this.ctx.storageUri?.fsPath,
|
|
);
|
|
await qlPackGenerator.generate();
|
|
} catch (e: unknown) {
|
|
void this.logger.log(
|
|
`Could not create skeleton QL pack: ${getErrorMessage(e)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async reregisterDatabases(
|
|
progress: ProgressCallback,
|
|
token: vscode.CancellationToken,
|
|
) {
|
|
let completed = 0;
|
|
await Promise.all(
|
|
this._databaseItems.map(async (databaseItem) => {
|
|
await this.registerDatabase(progress, token, databaseItem);
|
|
completed++;
|
|
progress({
|
|
maxStep: this._databaseItems.length,
|
|
step: completed,
|
|
message: "Re-registering databases",
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
public async addDatabaseSourceArchiveFolder(item: DatabaseItem) {
|
|
// The folder may already be in workspace state from a previous
|
|
// session. If not, add it.
|
|
const index = this.getDatabaseWorkspaceFolderIndex(item);
|
|
if (index === -1) {
|
|
// Add that filesystem as a folder to the current workspace.
|
|
//
|
|
// It's important that we add workspace folders to the end,
|
|
// rather than beginning of the list, because the first
|
|
// workspace folder is special; if it gets updated, the entire
|
|
// extension host is restarted. (cf.
|
|
// https://github.com/microsoft/vscode/blob/e0d2ed907d1b22808c56127678fb436d604586a7/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts#L209-L214)
|
|
//
|
|
// This is undesirable, as we might be adding and removing many
|
|
// workspace folders as the user adds and removes databases.
|
|
const end = (vscode.workspace.workspaceFolders || []).length;
|
|
|
|
const msg = item.verifyZippedSources();
|
|
if (msg) {
|
|
void extLogger.log(`Could not add source folder because ${msg}`);
|
|
return;
|
|
}
|
|
|
|
const uri = item.getSourceArchiveExplorerUri();
|
|
void extLogger.log(
|
|
`Adding workspace folder for ${item.name} source archive at index ${end}`,
|
|
);
|
|
if ((vscode.workspace.workspaceFolders || []).length < 2) {
|
|
// Adding this workspace folder makes the workspace
|
|
// multi-root, which may surprise the user. Let them know
|
|
// we're doing this.
|
|
void vscode.window.showInformationMessage(
|
|
`Adding workspace folder for source archive of database ${item.name}.`,
|
|
);
|
|
}
|
|
vscode.workspace.updateWorkspaceFolders(end, 0, {
|
|
name: `[${item.name} source archive]`,
|
|
uri,
|
|
});
|
|
// vscode api documentation says we must to wait for this event
|
|
// between multiple `updateWorkspaceFolders` calls.
|
|
await eventFired(vscode.workspace.onDidChangeWorkspaceFolders);
|
|
}
|
|
}
|
|
|
|
private async createDatabaseItemFromPersistedState(
|
|
progress: ProgressCallback,
|
|
token: vscode.CancellationToken,
|
|
state: PersistedDatabaseItem,
|
|
): Promise<DatabaseItem> {
|
|
let displayName: string | undefined = undefined;
|
|
let ignoreSourceArchive = false;
|
|
let dateAdded = undefined;
|
|
let language = undefined;
|
|
if (state.options) {
|
|
if (typeof state.options.displayName === "string") {
|
|
displayName = state.options.displayName;
|
|
}
|
|
if (typeof state.options.ignoreSourceArchive === "boolean") {
|
|
ignoreSourceArchive = state.options.ignoreSourceArchive;
|
|
}
|
|
if (typeof state.options.dateAdded === "number") {
|
|
dateAdded = state.options.dateAdded;
|
|
}
|
|
language = state.options.language;
|
|
}
|
|
|
|
const dbBaseUri = vscode.Uri.parse(state.uri, true);
|
|
if (language === undefined) {
|
|
// we haven't been successful yet at getting the language. try again
|
|
language = await this.getPrimaryLanguage(dbBaseUri.fsPath);
|
|
}
|
|
|
|
const fullOptions: FullDatabaseOptions = {
|
|
ignoreSourceArchive,
|
|
displayName,
|
|
dateAdded,
|
|
language,
|
|
};
|
|
const item = new DatabaseItemImpl(
|
|
dbBaseUri,
|
|
undefined,
|
|
fullOptions,
|
|
(event) => {
|
|
this._onDidChangeDatabaseItem.fire(event);
|
|
},
|
|
);
|
|
|
|
// Avoid persisting the database state after adding since that should happen only after
|
|
// all databases have been added.
|
|
await this.addDatabaseItem(progress, token, item, false);
|
|
return item;
|
|
}
|
|
|
|
public async loadPersistedState(): Promise<void> {
|
|
return withProgress(
|
|
{
|
|
location: vscode.ProgressLocation.Notification,
|
|
},
|
|
async (progress, token) => {
|
|
const currentDatabaseUri =
|
|
this.ctx.workspaceState.get<string>(CURRENT_DB);
|
|
const databases = this.ctx.workspaceState.get<PersistedDatabaseItem[]>(
|
|
DB_LIST,
|
|
[],
|
|
);
|
|
let step = 0;
|
|
progress({
|
|
maxStep: databases.length,
|
|
message: "Loading persisted databases",
|
|
step,
|
|
});
|
|
try {
|
|
void this.logger.log(
|
|
`Found ${databases.length} persisted databases: ${databases
|
|
.map((db) => db.uri)
|
|
.join(", ")}`,
|
|
);
|
|
for (const database of databases) {
|
|
progress({
|
|
maxStep: databases.length,
|
|
message: `Loading ${
|
|
database.options?.displayName || "databases"
|
|
}`,
|
|
step: ++step,
|
|
});
|
|
|
|
const databaseItem =
|
|
await this.createDatabaseItemFromPersistedState(
|
|
progress,
|
|
token,
|
|
database,
|
|
);
|
|
try {
|
|
await databaseItem.refresh();
|
|
await this.registerDatabase(progress, token, databaseItem);
|
|
if (currentDatabaseUri === database.uri) {
|
|
await this.setCurrentDatabaseItem(databaseItem, true);
|
|
}
|
|
void this.logger.log(
|
|
`Loaded database ${databaseItem.name} at URI ${database.uri}.`,
|
|
);
|
|
} catch (e) {
|
|
// When loading from persisted state, leave invalid databases in the list. They will be
|
|
// marked as invalid, and cannot be set as the current database.
|
|
void this.logger.log(
|
|
`Error loading database ${database.uri}: ${e}.`,
|
|
);
|
|
}
|
|
}
|
|
await this.updatePersistedDatabaseList();
|
|
} catch (e) {
|
|
// database list had an unexpected type - nothing to be done?
|
|
void showAndLogExceptionWithTelemetry(
|
|
redactableError(
|
|
asError(e),
|
|
)`Database list loading failed: ${getErrorMessage(e)}`,
|
|
);
|
|
}
|
|
|
|
void this.logger.log("Finished loading persisted databases.");
|
|
},
|
|
);
|
|
}
|
|
|
|
public get databaseItems(): readonly DatabaseItem[] {
|
|
return this._databaseItems;
|
|
}
|
|
|
|
public get currentDatabaseItem(): DatabaseItem | undefined {
|
|
return this._currentDatabaseItem;
|
|
}
|
|
|
|
public async setCurrentDatabaseItem(
|
|
item: DatabaseItem | undefined,
|
|
skipRefresh = false,
|
|
): Promise<void> {
|
|
if (!skipRefresh && item !== undefined) {
|
|
await item.refresh(); // Will throw on invalid database.
|
|
}
|
|
if (this._currentDatabaseItem !== item) {
|
|
this._currentDatabaseItem = item;
|
|
this.updatePersistedCurrentDatabaseItem();
|
|
|
|
await vscode.commands.executeCommand(
|
|
"setContext",
|
|
"codeQL.currentDatabaseItem",
|
|
item?.name,
|
|
);
|
|
|
|
this._onDidChangeCurrentDatabaseItem.fire({
|
|
item,
|
|
kind: DatabaseEventKind.Change,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the index of the workspace folder that corresponds to the source archive of `item`
|
|
* if there is one, and -1 otherwise.
|
|
*/
|
|
private getDatabaseWorkspaceFolderIndex(item: DatabaseItem): number {
|
|
return (vscode.workspace.workspaceFolders || []).findIndex((folder) =>
|
|
item.belongsToSourceArchiveExplorerUri(folder.uri),
|
|
);
|
|
}
|
|
|
|
public findDatabaseItem(uri: vscode.Uri): DatabaseItem | undefined {
|
|
const uriString = uri.toString(true);
|
|
return this._databaseItems.find(
|
|
(item) => item.databaseUri.toString(true) === uriString,
|
|
);
|
|
}
|
|
|
|
public findDatabaseItemBySourceArchive(
|
|
uri: vscode.Uri,
|
|
): DatabaseItem | undefined {
|
|
const uriString = uri.toString(true);
|
|
return this._databaseItems.find(
|
|
(item) =>
|
|
item.sourceArchive && item.sourceArchive.toString(true) === uriString,
|
|
);
|
|
}
|
|
|
|
private async addDatabaseItem(
|
|
progress: ProgressCallback,
|
|
token: vscode.CancellationToken,
|
|
item: DatabaseItem,
|
|
updatePersistedState = true,
|
|
) {
|
|
this._databaseItems.push(item);
|
|
|
|
if (updatePersistedState) {
|
|
await this.updatePersistedDatabaseList();
|
|
}
|
|
|
|
// Add this database item to the allow-list
|
|
// Database items reconstituted from persisted state
|
|
// will not have their contents yet.
|
|
if (item.contents?.datasetUri) {
|
|
await this.registerDatabase(progress, token, item);
|
|
}
|
|
// note that we use undefined as the item in order to reset the entire tree
|
|
this._onDidChangeDatabaseItem.fire({
|
|
item: undefined,
|
|
kind: DatabaseEventKind.Add,
|
|
});
|
|
}
|
|
|
|
public async renameDatabaseItem(item: DatabaseItem, newName: string) {
|
|
item.name = newName;
|
|
await this.updatePersistedDatabaseList();
|
|
this._onDidChangeDatabaseItem.fire({
|
|
// pass undefined so that the entire tree is rebuilt in order to re-sort
|
|
item: undefined,
|
|
kind: DatabaseEventKind.Rename,
|
|
});
|
|
}
|
|
|
|
public async removeDatabaseItem(
|
|
progress: ProgressCallback,
|
|
token: vscode.CancellationToken,
|
|
item: DatabaseItem,
|
|
) {
|
|
if (this._currentDatabaseItem === item) {
|
|
this._currentDatabaseItem = undefined;
|
|
}
|
|
const index = this.databaseItems.findIndex(
|
|
(searchItem) => searchItem === item,
|
|
);
|
|
if (index >= 0) {
|
|
this._databaseItems.splice(index, 1);
|
|
}
|
|
await this.updatePersistedDatabaseList();
|
|
|
|
// Delete folder from workspace, if it is still there
|
|
const folderIndex = (vscode.workspace.workspaceFolders || []).findIndex(
|
|
(folder) => item.belongsToSourceArchiveExplorerUri(folder.uri),
|
|
);
|
|
if (folderIndex >= 0) {
|
|
void extLogger.log(`Removing workspace folder at index ${folderIndex}`);
|
|
vscode.workspace.updateWorkspaceFolders(folderIndex, 1);
|
|
}
|
|
|
|
// Remove this database item from the allow-list
|
|
await this.deregisterDatabase(progress, token, item);
|
|
|
|
// Delete folder from file system only if it is controlled by the extension
|
|
if (this.isExtensionControlledLocation(item.databaseUri)) {
|
|
void extLogger.log("Deleting database from filesystem.");
|
|
await remove(item.databaseUri.fsPath).then(
|
|
() => void extLogger.log(`Deleted '${item.databaseUri.fsPath}'`),
|
|
(e: unknown) =>
|
|
void extLogger.log(
|
|
`Failed to delete '${
|
|
item.databaseUri.fsPath
|
|
}'. Reason: ${getErrorMessage(e)}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
// note that we use undefined as the item in order to reset the entire tree
|
|
this._onDidChangeDatabaseItem.fire({
|
|
item: undefined,
|
|
kind: DatabaseEventKind.Remove,
|
|
});
|
|
}
|
|
|
|
private async deregisterDatabase(
|
|
progress: ProgressCallback,
|
|
token: vscode.CancellationToken,
|
|
dbItem: DatabaseItem,
|
|
) {
|
|
await this.qs.deregisterDatabase(progress, token, dbItem);
|
|
}
|
|
private async registerDatabase(
|
|
progress: ProgressCallback,
|
|
token: vscode.CancellationToken,
|
|
dbItem: DatabaseItem,
|
|
) {
|
|
await this.qs.registerDatabase(progress, token, dbItem);
|
|
}
|
|
|
|
private updatePersistedCurrentDatabaseItem(): void {
|
|
void this.ctx.workspaceState.update(
|
|
CURRENT_DB,
|
|
this._currentDatabaseItem
|
|
? this._currentDatabaseItem.databaseUri.toString(true)
|
|
: undefined,
|
|
);
|
|
}
|
|
|
|
private async updatePersistedDatabaseList(): Promise<void> {
|
|
await this.ctx.workspaceState.update(
|
|
DB_LIST,
|
|
this._databaseItems.map((item) => item.getPersistedState()),
|
|
);
|
|
}
|
|
|
|
private isExtensionControlledLocation(uri: vscode.Uri) {
|
|
const storagePath = this.ctx.storagePath || this.ctx.globalStoragePath;
|
|
// the uri.fsPath function on windows returns a lowercase drive letter,
|
|
// but storagePath will have an uppercase drive letter. Be sure to compare
|
|
// URIs to URIs only
|
|
if (storagePath) {
|
|
return uri.fsPath.startsWith(vscode.Uri.file(storagePath).fsPath);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async getPrimaryLanguage(dbPath: string) {
|
|
const dbInfo = await this.cli.resolveDatabase(dbPath);
|
|
return dbInfo.languages?.[0] || "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the set of directories containing upgrades, given a list of
|
|
* scripts returned by the cli's upgrade resolution.
|
|
*/
|
|
export function getUpgradesDirectories(scripts: string[]): vscode.Uri[] {
|
|
const parentDirs = scripts.map((dir) => dirname(dir));
|
|
const uniqueParentDirs = new Set(parentDirs);
|
|
return Array.from(uniqueParentDirs).map((filePath) =>
|
|
vscode.Uri.file(filePath),
|
|
);
|
|
}
|