diff --git a/extensions/query-history/package.json b/extensions/query-history/package.json index eb92eb9929..d066f07d98 100644 --- a/extensions/query-history/package.json +++ b/extensions/query-history/package.json @@ -26,9 +26,7 @@ "supported": true } }, - "extensionDependencies": [ - "Microsoft.mssql" - ], + "extensionDependencies": [ ], "contributes": { "configuration": [ { @@ -52,6 +50,11 @@ "%queryHistory.doubleClickAction.open%", "%queryHistory.doubleClickAction.run%" ] + }, + "queryHistory.persistHistory": { + "type": "boolean", + "default": true, + "description": "%queryHistory.persistHistory%" } } } @@ -188,9 +191,11 @@ ] } }, - "dependencies": {}, + "dependencies": { + "vscode-nls": "^4.1.2" + }, "devDependencies": { - "@microsoft/azdata-test": "^2.0.3", + "@microsoft/azdata-test": "^3.0.0", "@microsoft/vscodetestcover": "^1.2.1", "@types/mocha": "^7.0.2", "@types/node": "^12.11.7", diff --git a/extensions/query-history/package.nls.json b/extensions/query-history/package.nls.json index e5a4caeb1a..28808c2451 100644 --- a/extensions/query-history/package.nls.json +++ b/extensions/query-history/package.nls.json @@ -5,6 +5,7 @@ "queryHistory.doubleClickAction": "The action taken when a history item is double clicked", "queryHistory.doubleClickAction.open": "Open a new disconnected editor with the query from the selected history item", "queryHistory.doubleClickAction.run": "Open a new connected editor with the query and connection from the selected history item and automatically run the query", + "queryHistory.persistHistory": "Whether query history is persisted across restarts. If false then the history will be cleared when the application exits.", "queryHistory.open": "Open Query", "queryHistory.run": "Run Query", "queryHistory.delete": "Delete", diff --git a/extensions/query-history/src/constants.ts b/extensions/query-history/src/constants.ts index 60e376647e..f8ca2d1a33 100644 --- a/extensions/query-history/src/constants.ts +++ b/extensions/query-history/src/constants.ts @@ -6,5 +6,7 @@ export const QUERY_HISTORY_CONFIG_SECTION = 'queryHistory'; export const CAPTURE_ENABLED_CONFIG_SECTION = 'captureEnabled'; export const DOUBLE_CLICK_ACTION_CONFIG_SECTION = 'doubleClickAction'; +export const PERSIST_HISTORY_CONFIG_SECTION = 'persistHistory'; export const ITEM_SELECTED_COMMAND_ID = 'queryHistory.itemSelected'; + diff --git a/extensions/query-history/src/localizedConstants.ts b/extensions/query-history/src/localizedConstants.ts new file mode 100644 index 0000000000..882ca3e3ac --- /dev/null +++ b/extensions/query-history/src/localizedConstants.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +export const errorLoading = (err: any): string => localize('errorLoading', "Error loading saved query history items. {0}", err.message ?? err); + diff --git a/extensions/query-history/src/main.ts b/extensions/query-history/src/main.ts index 15b3a1afb4..e6174888cd 100644 --- a/extensions/query-history/src/main.ts +++ b/extensions/query-history/src/main.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; import { DOUBLE_CLICK_ACTION_CONFIG_SECTION, ITEM_SELECTED_COMMAND_ID, QUERY_HISTORY_CONFIG_SECTION } from './constants'; import { QueryHistoryItem } from './queryHistoryItem'; import { QueryHistoryProvider } from './queryHistoryProvider'; +import { promises as fs } from 'fs'; let lastSelectedItem: { item: QueryHistoryItem | undefined, time: number | undefined } = { item: undefined, @@ -19,7 +20,15 @@ let lastSelectedItem: { item: QueryHistoryItem | undefined, time: number | undef const DOUBLE_CLICK_TIMEOUT_MS = 500; export async function activate(context: vscode.ExtensionContext): Promise { - const treeDataProvider = new QueryHistoryProvider(); + // Create the global storage folder now for storing the query history persistance file + try { + await fs.mkdir(context.globalStorageUri.fsPath); + } catch (err) { + if (err.code !== 'EEXIST') { + console.error(`Error creating query history global storage folder ${context.globalStorageUri.fsPath}. ${err}`); + } + } + const treeDataProvider = new QueryHistoryProvider(context); context.subscriptions.push(treeDataProvider); const treeView = vscode.window.createTreeView('queryHistory', { treeDataProvider, @@ -63,10 +72,10 @@ export async function activate(context: vscode.ExtensionContext): Promise return runQuery(item); })); context.subscriptions.push(vscode.commands.registerCommand('queryHistory.delete', (item: QueryHistoryItem) => { - treeDataProvider.deleteItem(item); + return treeDataProvider.deleteItem(item); })); context.subscriptions.push(vscode.commands.registerCommand('queryHistory.clear', () => { - treeDataProvider.clearAll(); + return treeDataProvider.clearAll(); })); context.subscriptions.push(vscode.commands.registerCommand('queryHistory.disableCapture', async () => { return treeDataProvider.setCaptureEnabled(false); @@ -88,6 +97,10 @@ async function runQuery(item: QueryHistoryItem): Promise { { content: item.queryText }, item.connectionProfile?.providerId); - await azdata.queryeditor.connect(doc.uri, item.connectionProfile?.connectionId || ''); + if (item.connectionProfile) { + await doc.connect(item.connectionProfile); + } else { + await azdata.queryeditor.connect(doc.uri, ''); + } azdata.queryeditor.runQuery(doc.uri); } diff --git a/extensions/query-history/src/queryHistoryItem.ts b/extensions/query-history/src/queryHistoryItem.ts index 2129f166f7..d56db1ad4b 100644 --- a/extensions/query-history/src/queryHistoryItem.ts +++ b/extensions/query-history/src/queryHistoryItem.ts @@ -8,6 +8,6 @@ import * as azdata from 'azdata'; export interface QueryHistoryItem { readonly queryText: string, readonly connectionProfile: azdata.connection.ConnectionProfile | undefined, - readonly timestamp: Date, + readonly timestamp: string, readonly isSuccess: boolean } diff --git a/extensions/query-history/src/queryHistoryProvider.ts b/extensions/query-history/src/queryHistoryProvider.ts index c822f1f729..134377260a 100644 --- a/extensions/query-history/src/queryHistoryProvider.ts +++ b/extensions/query-history/src/queryHistoryProvider.ts @@ -6,10 +6,20 @@ import * as vscode from 'vscode'; import * as azdata from 'azdata'; import { QueryHistoryItem } from './queryHistoryItem'; -import { removeNewLines } from './utils'; -import { CAPTURE_ENABLED_CONFIG_SECTION, ITEM_SELECTED_COMMAND_ID, QUERY_HISTORY_CONFIG_SECTION } from './constants'; +import { debounce, removeNewLines } from './utils'; +import { CAPTURE_ENABLED_CONFIG_SECTION, ITEM_SELECTED_COMMAND_ID, PERSIST_HISTORY_CONFIG_SECTION, QUERY_HISTORY_CONFIG_SECTION } from './constants'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as loc from './localizedConstants'; +const STORAGE_IV_KEY = 'queryHistory.storage-iv'; +const STORAGE_KEY_KEY = 'queryHistory.storage-key'; +const HISTORY_STORAGE_FILE_NAME = 'queryHistory.bin'; +const STORAGE_ENCRYPTION_ALGORITHM = 'aes-256-ctr'; +const HISTORY_DEBOUNCE_MS = 10000; const DEFAULT_CAPTURE_ENABLED = true; +const DEFAULT_PERSIST_HISTORY = true; const successIcon = new vscode.ThemeIcon('check', new vscode.ThemeColor('testing.iconPassed')); const failedIcon = new vscode.ThemeIcon('error', new vscode.ThemeColor('testing.iconFailed')); @@ -19,16 +29,30 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider = this._onDidChangeTreeData.event; private _queryHistoryItems: QueryHistoryItem[] = []; - private _captureEnabled: boolean = true; + private _captureEnabled: boolean = DEFAULT_CAPTURE_ENABLED; + private _persistHistory: boolean = DEFAULT_PERSIST_HISTORY; + + private _historyStorageFile: string; private _disposables: vscode.Disposable[] = []; + private writeHistoryFileWorker: (() => void) | undefined; + + /** * Mapping of query URIs to the query text being executed */ private queryTextMappings: Map = new Map(); - constructor() { + constructor(private _context: vscode.ExtensionContext) { + this._historyStorageFile = path.join(this._context.globalStorageUri.fsPath, HISTORY_STORAGE_FILE_NAME); + // Kick off initialization but then continue on since that may take a while and we don't want to block extension activation + void this.initialize(); + this._disposables.push(vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(QUERY_HISTORY_CONFIG_SECTION)) { + await this.updateConfigurationValues(); + } + })); this._disposables.push(azdata.queryeditor.registerQueryEventListener({ onQueryEvent: async (type: azdata.queryeditor.QueryEventType, document: azdata.queryeditor.QueryDocument, args: azdata.ResultSetSummary | string | undefined, queryInfo?: azdata.queryeditor.QueryInfo) => { if (this._captureEnabled && queryInfo) { @@ -42,8 +66,9 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider { - if (e.affectsConfiguration(QUERY_HISTORY_CONFIG_SECTION)) { - this.updateCaptureEnabled(); + } + + /** + * Initializes the provider, loading the history from the previous session if it exists. + * @returns + */ + private async initialize(): Promise { + // First update our configuration values to make sure we have the settings the user has configured + await this.updateConfigurationValues(); + + let iv: Buffer | undefined; + try { + let ivString = await this._context.secrets.get(STORAGE_IV_KEY); + if (!ivString) { + iv = crypto.randomBytes(16); + await this._context.secrets.store(STORAGE_IV_KEY, iv.toString('binary')); + } else { + iv = Buffer.from(ivString, 'binary'); } - })); + } catch (err) { + console.error(`Error getting persistance storage IV: ${err}`); + // An IV is required to read/write the encrypted file so if we can't get it then just fail early + return; + } + + + let key: string | undefined; + try { + key = await this._context.secrets.get(STORAGE_KEY_KEY); + if (!key) { + // Generate a random key - this is internal to the extension so the user doesn't need to know it + key = crypto.createHash('sha256').update(crypto.randomBytes(64)).digest('base64').substring(0, 32); + await this._context.secrets.store(STORAGE_KEY_KEY, key); + } + } catch (err) { + console.error(`Error getting persistance storage key: ${err}`); + // A key is required to read/write the encrypted file so if we can't get it then just fail early + return; + } + + this.writeHistoryFileWorker = (): void => { + if (this._persistHistory) { + try { + // We store the history entries in an encrypted file because they may contain sensitive information + // such as passwords (even in the query text itself) + const cipher = crypto.createCipheriv(STORAGE_ENCRYPTION_ALGORITHM, key!, iv!); + const stringifiedItems = JSON.stringify(this._queryHistoryItems); + const encryptedText = Buffer.concat([cipher.update(Buffer.from(stringifiedItems)), cipher.final()]); + // Use sync here so that we can write this out when the object is disposed + fs.writeFileSync(this._historyStorageFile, encryptedText); + } catch (err) { + console.error(`Error writing query history to disk: ${err}`); + } + + } + }; + + // If we're not persisting the history then we can skip even trying to load the file (which shouldn't exist) + if (!this._persistHistory) { + return; + } + + try { + // Read and decrypt any previous history items + const encryptedItems = await fs.promises.readFile(this._historyStorageFile); + const decipher = crypto.createDecipheriv(STORAGE_ENCRYPTION_ALGORITHM, key, iv); + const result = Buffer.concat([decipher.update(encryptedItems), decipher.final()]).toString(); + this._queryHistoryItems = JSON.parse(result); + this._onDidChangeTreeData.fire(undefined); + } catch (err) { + // Ignore ENOENT errors, those are expected if the storage file doesn't exist (on first run or if results aren't being persisted) + if (err.code !== 'ENOENT') { + console.error(`Error deserializing stored history items: ${err}`); + void vscode.window.showWarningMessage(loc.errorLoading(err)); + // Rename the file to avoid attempting to load a potentially corrupted or unreadable file every time we start up, we'll make + // a new one next time we write the history file + try { + const bakPath = path.join(path.dirname(this._historyStorageFile), `${HISTORY_STORAGE_FILE_NAME}.bak`); + await fs.promises.rename(this._historyStorageFile, bakPath); + } catch (err) { + console.error(`Error moving corrupted history file: ${err}`); + } + } + + } } - public clearAll(): void { + /** + * Write the query history items to our encrypted file. This is debounced to + * prevent doing unnecessary writes if the user is executing many queries in + * a row + */ + @debounce(HISTORY_DEBOUNCE_MS) + private writeHistoryFile(): void { + this.writeHistoryFileWorker?.(); + } + + public async clearAll(): Promise { this._queryHistoryItems = []; + this.writeHistoryFile(); this._onDidChangeTreeData.fire(undefined); } - public deleteItem(item: QueryHistoryItem): void { + public async deleteItem(item: QueryHistoryItem): Promise { this._queryHistoryItems = this._queryHistoryItems.filter(n => n !== item); + this.writeHistoryFile(); this._onDidChangeTreeData.fire(undefined); } + public getTreeItem(item: QueryHistoryItem): vscode.TreeItem { const treeItem = new vscode.TreeItem(removeNewLines(item.queryText), vscode.TreeItemCollapsibleState.None); treeItem.iconPath = item.isSuccess ? successIcon : failedIcon; treeItem.tooltip = item.queryText; - treeItem.description = item.connectionProfile ? `${item.connectionProfile.serverName}|${item.connectionProfile.databaseName} ${item.timestamp.toLocaleString()}` : item.timestamp.toLocaleString(); + treeItem.description = item.connectionProfile ? `${item.connectionProfile.serverName}|${item.connectionProfile.databaseName} ${item.timestamp}` : item.timestamp; treeItem.command = { title: '', command: ITEM_SELECTED_COMMAND_ID, arguments: [item] }; return treeItem; } @@ -97,10 +214,28 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider d.dispose()); + // Call the worker directly to skip the debounce + this.writeHistoryFileWorker?.(); } - private updateCaptureEnabled(): void { - this._captureEnabled = vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION).get(CAPTURE_ENABLED_CONFIG_SECTION) ?? DEFAULT_CAPTURE_ENABLED; + private async updateConfigurationValues(): Promise { + const configSection = vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION); + this._captureEnabled = configSection.get(CAPTURE_ENABLED_CONFIG_SECTION, DEFAULT_CAPTURE_ENABLED); + this._persistHistory = configSection.get(PERSIST_HISTORY_CONFIG_SECTION, DEFAULT_PERSIST_HISTORY); + if (!this._persistHistory) { + // If we're no longer persisting the history then clean up our storage file + try { + await fs.promises.rmdir(this._historyStorageFile); + } catch (err) { + // Ignore ENOENT errors, those are expected if the storage file doesn't exist (on first run or if results aren't being persisted) + if (err.code !== 'ENOENT') { + // Best effort, we don't want other things to fail if we can't delete the file for some reason + console.error(`Error cleaning up query history storage: ${this._historyStorageFile}. ${err}`); + } + } + } else { + this.writeHistoryFile(); + } } /** @@ -112,4 +247,14 @@ export class QueryHistoryProvider implements vscode.TreeDataProvider { + this._persistHistory = enabled; + return vscode.workspace.getConfiguration(QUERY_HISTORY_CONFIG_SECTION).update(PERSIST_HISTORY_CONFIG_SECTION, this._persistHistory, vscode.ConfigurationTarget.Global); + } } diff --git a/extensions/query-history/src/test/queryHistoryProvider.test.ts b/extensions/query-history/src/test/queryHistoryProvider.test.ts index 0239425deb..60eb139d3f 100644 --- a/extensions/query-history/src/test/queryHistoryProvider.test.ts +++ b/extensions/query-history/src/test/queryHistoryProvider.test.ts @@ -19,7 +19,7 @@ describe('QueryHistoryProvider', () => { let textDocumentSandbox: sinon.SinonSandbox; const testUri = vscode.Uri.parse('untitled://query1'); - beforeEach(function (): void { + beforeEach(async function (): Promise { sinon.stub(azdata.queryeditor, 'registerQueryEventListener').callsFake((listener: azdata.queryeditor.QueryEventListener) => { testListener = listener; return { dispose: (): void => { } }; @@ -28,7 +28,11 @@ describe('QueryHistoryProvider', () => { textDocumentSandbox.replaceGetter(vscode.workspace, 'textDocuments', () => [azdataTest.mocks.vscode.createTextDocumentMock(testUri).object]); const getConnectionStub = sinon.stub(azdata.connection, 'getConnection'); getConnectionStub.resolves({}); - testProvider = new QueryHistoryProvider(); + // const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration') + const contextMock = azdataTest.mocks.vscode.createExtensionContextMock(); + testProvider = new QueryHistoryProvider(contextMock.object); + // Disable persistence during tests + await testProvider.setPersistenceEnabled(false); }); afterEach(function (): void { @@ -40,8 +44,8 @@ describe('QueryHistoryProvider', () => { should(children).length(0); }); - it('Clearing empty list does not throw', function () { - testProvider.clearAll(); + it('Clearing empty list does not throw', async function () { + await testProvider.clearAll(); const children = testProvider.getChildren(); should(children).length(0); }); @@ -138,7 +142,7 @@ describe('QueryHistoryProvider', () => { }); it('delete item when no items doesn\'t throw', async function () { - const testItem: QueryHistoryItem = { queryText: 'SELECT 1', connectionProfile: azdataTest.stubs.connectionProfile.createConnectionProfile(), timestamp: new Date(), isSuccess: true }; + const testItem: QueryHistoryItem = { queryText: 'SELECT 1', connectionProfile: azdataTest.stubs.azdata.createConnectionProfile(), timestamp: new Date().toLocaleString(), isSuccess: true }; await waitForItemRefresh(() => testProvider.deleteItem(testItem)); const children = testProvider.getChildren(); should(children).length(0, 'Should have no children after deleting item'); @@ -149,7 +153,7 @@ describe('QueryHistoryProvider', () => { let children = testProvider.getChildren(); should(children).length(1, 'Should have 1 child initially'); - const testItem: QueryHistoryItem = { queryText: 'SELECT 1', connectionProfile: azdataTest.stubs.connectionProfile.createConnectionProfile(), timestamp: new Date(), isSuccess: true }; + const testItem: QueryHistoryItem = { queryText: 'SELECT 1', connectionProfile: azdataTest.stubs.azdata.createConnectionProfile(), timestamp: new Date().toLocaleString(), isSuccess: true }; await waitForItemRefresh(() => testProvider.deleteItem(testItem)); children = testProvider.getChildren(); should(children).length(1, 'Should still have 1 child after deleting item'); @@ -213,16 +217,16 @@ describe('QueryHistoryProvider', () => { } async function fireQueryEventAndWaitForRefresh(type: azdata.queryeditor.QueryEventType, document: azdata.queryeditor.QueryDocument, queryInfo: azdata.queryeditor.QueryInfo, timeoutMs?: number): Promise { - await waitForItemRefresh(() => testListener.onQueryEvent(type, document, undefined, queryInfo), timeoutMs); + await waitForItemRefresh(async () => testListener.onQueryEvent(type, document, undefined, queryInfo), timeoutMs); } - async function waitForItemRefresh(func: Function, timeoutMs?: number): Promise { + async function waitForItemRefresh(func: () => Promise, timeoutMs?: number): Promise { const promises: Promise[] = [azdataTest.helpers.eventToPromise(testProvider.onDidChangeTreeData)]; const timeoutPromise = timeoutMs ? new Promise(r => setTimeout(() => r(), timeoutMs)) : undefined; if (timeoutPromise) { promises.push(timeoutPromise); } - func(); + await func(); await Promise.race(promises); } }); diff --git a/extensions/query-history/src/utils.ts b/extensions/query-history/src/utils.ts index ee8f9fe324..00b8c73d5d 100644 --- a/extensions/query-history/src/utils.ts +++ b/extensions/query-history/src/utils.ts @@ -11,3 +11,35 @@ export function removeNewLines(str: string): string { return str.replace(/\r\n/g, ' ').replace(/\n/g, ' '); } + +function decorate(decorator: (fn: Function, key: string) => Function): Function { + return (_target: any, key: string, descriptor: any): void => { + let fnKey: string | null = null; + let fn: Function | null = null; + + if (typeof descriptor.value === 'function') { + fnKey = 'value'; + fn = descriptor.value; + } else if (typeof descriptor.get === 'function') { + fnKey = 'get'; + fn = descriptor.get; + } + + if (!fn || !fnKey) { + throw new Error('not supported'); + } + + descriptor[fnKey] = decorator(fn, key); + }; +} + +export function debounce(delay: number): Function { + return decorate((fn, key) => { + const timerKey = `$debounce$${key}`; + + return function (this: any, ...args: any[]): void { + clearTimeout(this[timerKey]); + this[timerKey] = setTimeout(() => fn.apply(this, args), delay); + }; + }); +} diff --git a/extensions/query-history/yarn.lock b/extensions/query-history/yarn.lock index fcf32aef7a..f78bbd8088 100644 --- a/extensions/query-history/yarn.lock +++ b/extensions/query-history/yarn.lock @@ -228,10 +228,10 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@microsoft/azdata-test@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@microsoft/azdata-test/-/azdata-test-2.0.3.tgz#652984efa2f5adc56cdae9029a4d5f33446b54d3" - integrity sha512-BgB6gGjQVXxnZHq7o5TlajZR/mJd/6AqbclrGzoyATvCEt92jRXhPzaY6XA/jMahdUGFSQwXpm45qRhZetwDig== +"@microsoft/azdata-test@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@microsoft/azdata-test/-/azdata-test-3.0.0.tgz#5cc4f416e464e6127dc333afaf983ebe49a66f37" + integrity sha512-W0KsXlVF5l0SIcy0HuDqy289o/C1D5AfYxkrlezCeACKsEXyKBsecVojgAY/YN26K5L5AFKXJYaRcqOeq0k76w== dependencies: http-proxy-agent "^2.1.0" https-proxy-agent "^2.2.4" @@ -1509,6 +1509,11 @@ update-browserslist-db@^1.0.4: escalade "^3.1.1" picocolors "^1.0.0" +vscode-nls@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-4.1.2.tgz#ca8bf8bb82a0987b32801f9fddfdd2fb9fd3c167" + integrity sha512-7bOHxPsfyuCqmP+hZXscLhiHwe7CSuFE4hyhbs22xPIhQ4jv99FcR4eBzfYYVLP356HNFpdvz63FFb/xw6T4Iw== + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"