diff --git a/extensions/notebook/package.json b/extensions/notebook/package.json index 2905aeab4baf..657292a07f73 100644 --- a/extensions/notebook/package.json +++ b/extensions/notebook/package.json @@ -41,6 +41,14 @@ "type": "number", "default": 5000, "description": "%notebook.maxTableRows.description%" + }, + "notebook.trustedBooks":{ + "type": "array", + "default": [], + "description": "%notebook.trustedBooks.description%", + "items": { + "type": "string" + } } } }, @@ -159,6 +167,15 @@ "light": "resources/light/save.svg" } }, + { + "command": "notebook.command.trustBook", + "title": "%title.trustBook%", + "category": "%books-preview-category%", + "icon": { + "dark": "resources/dark/trust_inverse.svg", + "light": "resources/light/trust.svg" + } + }, { "command": "notebook.command.searchBook", "title": "%title.searchJupyterBook%", @@ -292,6 +309,10 @@ "command": "notebook.command.searchUntitledBook", "when": "false" }, + { + "command": "notebook.command.trustBook", + "when": "view == bookTreeView && viewItem == savedBook" + }, { "command": "notebook.command.closeBook", "when": "false" @@ -326,6 +347,11 @@ } ], "view/item/context": [ + { + "command": "notebook.command.trustBook", + "when": "view == bookTreeView && viewItem == savedBook", + "group": "inline" + }, { "command": "notebook.command.searchBook", "when": "view == bookTreeView && viewItem == savedBook || viewItem == section", diff --git a/extensions/notebook/package.nls.json b/extensions/notebook/package.nls.json index 8effe01d3cc6..012749963b07 100644 --- a/extensions/notebook/package.nls.json +++ b/extensions/notebook/package.nls.json @@ -6,6 +6,7 @@ "notebook.useExistingPython.description": "Local path to a preexisting python installation used by Notebooks.", "notebook.overrideEditorTheming.description": "Override editor default settings in the Notebook editor. Settings include background color, current line color and border", "notebook.maxTableRows.description": "Maximum number of rows returned per table in the Notebook editor", + "notebook.trustedBooks.description": "Notebooks contained in these books will automatically be trusted.", "notebook.maxBookSearchDepth.description": "Maximum depth of subdirectories to search for Books (Enter 0 for infinite)", "notebook.command.new": "New Notebook", "notebook.command.open": "Open Notebook", @@ -30,6 +31,7 @@ "title.SQL19PreviewBook": "SQL Server 2019 Guide", "books-preview-category": "Jupyter Books", "title.saveJupyterBook": "Save Book", + "title.trustBook": "Trust Book", "title.searchJupyterBook": "Search Book", "title.SavedBooks": "Saved Books", "title.UnsavedBooks": "Unsaved Books", diff --git a/extensions/notebook/resources/dark/trust_inverse.svg b/extensions/notebook/resources/dark/trust_inverse.svg new file mode 100644 index 000000000000..55101847b419 --- /dev/null +++ b/extensions/notebook/resources/dark/trust_inverse.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/notebook/resources/light/trust.svg b/extensions/notebook/resources/light/trust.svg new file mode 100644 index 000000000000..13f149f0de3f --- /dev/null +++ b/extensions/notebook/resources/light/trust.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/notebook/src/book/bookTreeView.ts b/extensions/notebook/src/book/bookTreeView.ts index 81b5b9c3d3fe..cd7c71076c0f 100644 --- a/extensions/notebook/src/book/bookTreeView.ts +++ b/extensions/notebook/src/book/bookTreeView.ts @@ -7,19 +7,20 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs-extra'; +import * as constants from '../common/constants'; import * as fsw from 'fs'; import { IPrompter, QuestionTypes, IQuestion } from '../prompts/question'; import CodeAdapter from '../prompts/adapter'; import { BookTreeItem } from './bookTreeItem'; import { BookModel } from './bookModel'; import { Deferred } from '../common/promise'; +import { IBookTrustManager, BookTrustManager } from './bookTrustManager'; import * as loc from '../common/localizedConstants'; import { ApiWrapper } from '../common/apiWrapper'; const Content = 'content'; export class BookTreeViewProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; private _throttleTimer: any; @@ -27,21 +28,22 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider = new Deferred(); - private _bookViewer: vscode.TreeView; private _openAsUntitled: boolean; - private _apiWrapper: ApiWrapper; + private _bookTrustManager: IBookTrustManager; + + private _bookViewer: vscode.TreeView; public viewId: string; public books: BookModel[]; public currentBook: BookModel; - constructor(apiWrapper: ApiWrapper, workspaceFolders: vscode.WorkspaceFolder[], extensionContext: vscode.ExtensionContext, openAsUntitled: boolean, view: string) { + constructor(private _apiWrapper: ApiWrapper, workspaceFolders: vscode.WorkspaceFolder[], extensionContext: vscode.ExtensionContext, openAsUntitled: boolean, view: string) { this._openAsUntitled = openAsUntitled; this._extensionContext = extensionContext; this.books = []; this.initialize(workspaceFolders).catch(e => console.error(e)); this.viewId = view; this.prompter = new CodeAdapter(); - this._apiWrapper = apiWrapper ? apiWrapper : new ApiWrapper(); + this._bookTrustManager = new BookTrustManager(this.books, _apiWrapper); } private async initialize(workspaceFolders: vscode.WorkspaceFolder[]): Promise { @@ -60,6 +62,28 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { try { let books: BookModel[] = this.books.filter(book => book.bookPath === bookPath) || []; @@ -157,6 +181,19 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { + document.setTrusted(true); + this._visitedNotebooks = this._visitedNotebooks.concat([normalizedResource]); + openDocumentListenerUnsubscriber.dispose(); + }); + } + let doc = await vscode.workspace.openTextDocument(resource); vscode.window.showTextDocument(doc); } @@ -377,6 +414,4 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider trustableBookPaths.some(trustableBookPath => trustableBookPath === path.join(bookItem.book.root, path.sep))) + .some(bookItem => normalizedNotebookUri.startsWith(path.join(bookItem.root, 'content', path.sep))); + let isNotebookTrusted = hasTrustedBookPath && this.books.some(bookModel => bookModel.getNotebook(normalizedNotebookUri)); + return isNotebookTrusted; + } + + getTrustableBookPaths() { + let trustablePaths: string[]; + let bookPathsInConfig: string[] = this.getTrustedBookPathsInConfig(); + + if (this.hasWorkspaceFolders()) { + let workspaceFolders = this.apiWrapper.getWorkspaceFolders(); + + trustablePaths = bookPathsInConfig + .map(trustableBookPath => workspaceFolders + .map(workspaceFolder => path.join(workspaceFolder.uri.fsPath, trustableBookPath))) + .reduce((accumulator, currentTrustableBookPaths) => accumulator.concat(currentTrustableBookPaths), []); + + } else { + trustablePaths = bookPathsInConfig; + } + + return trustablePaths; + } + + getBookTreeItems(): BookTreeItem[] { + return this.books + .map(book => book.bookItems) // select all the books + .reduce((accumulator, currentBookItemList) => accumulator.concat(currentBookItemList), []); + } + + setBookAsTrusted(bookRootPath: string): boolean { + return this.updateTrustedBooks(bookRootPath, TrustBookOperation.Add); + } + + getTrustedBookPathsInConfig(): string[] { + let config: vscode.WorkspaceConfiguration = this.apiWrapper.getConfiguration(constants.notebookConfigKey); + let trustedBookDirectories: string[] = config.get(constants.trustedBooksConfigKey); + + return trustedBookDirectories; + } + + setTrustedBookPathsInConfig(bookPaths: string[]) { + let config: vscode.WorkspaceConfiguration = this.apiWrapper.getConfiguration(constants.notebookConfigKey); + let storeInWorspace: boolean = this.hasWorkspaceFolders(); + + config.update(constants.trustedBooksConfigKey, bookPaths, storeInWorspace ? false : vscode.ConfigurationTarget.Global); + } + + hasWorkspaceFolders(): boolean { + let workspaceFolders = this.apiWrapper.getWorkspaceFolders(); + return workspaceFolders && workspaceFolders.length > 0; + } + + updateTrustedBooks(bookPath: string, operation: TrustBookOperation) { + let modifiedTrustedBooks = false; + let bookPathToChange: string = path.join(bookPath, path.sep); + + if (this.hasWorkspaceFolders()) { + let workspaceFolders = this.apiWrapper.getWorkspaceFolders(); + let matchingWorkspaceFolder: vscode.WorkspaceFolder = workspaceFolders + .find(ws => bookPathToChange.startsWith(path.normalize(ws.uri.fsPath))); + + // if notebook is stored in a workspace folder, then store only the relative directory + if (matchingWorkspaceFolder) { + bookPathToChange = bookPathToChange.replace(path.normalize(matchingWorkspaceFolder.uri.fsPath), ''); + } + } + + let trustedBooks: string[] = this.getTrustedBookPathsInConfig(); + let existingBookIndex = trustedBooks.map(trustedBookPath => path.normalize(trustedBookPath)).indexOf(bookPathToChange); + + if (existingBookIndex !== -1 && operation === TrustBookOperation.Remove) { + trustedBooks.splice(existingBookIndex, 1); + modifiedTrustedBooks = true; + } else if (existingBookIndex === -1 && operation === TrustBookOperation.Add) { + trustedBooks.push(bookPathToChange); + modifiedTrustedBooks = true; + } + + this.setTrustedBookPathsInConfig(trustedBooks); + + return modifiedTrustedBooks; + } +} diff --git a/extensions/notebook/src/common/apiWrapper.ts b/extensions/notebook/src/common/apiWrapper.ts index 96a3d0581ad6..370fb6eafec7 100644 --- a/extensions/notebook/src/common/apiWrapper.ts +++ b/extensions/notebook/src/common/apiWrapper.ts @@ -12,6 +12,10 @@ import { CommandContext, BuiltInCommands } from './constants'; * this API from our code */ export class ApiWrapper { + public getWorkspaceFolders(): vscode.WorkspaceFolder[] { + return [].concat(vscode.workspace.workspaceFolders || []); + } + public createOutputChannel(name: string): vscode.OutputChannel { return vscode.window.createOutputChannel(name); } diff --git a/extensions/notebook/src/common/constants.ts b/extensions/notebook/src/common/constants.ts index 26722b972a05..90d7dd86c2b7 100644 --- a/extensions/notebook/src/common/constants.ts +++ b/extensions/notebook/src/common/constants.ts @@ -16,6 +16,7 @@ export const pythonVersion = '3.6.6'; export const pythonPathConfigKey = 'pythonPath'; export const existingPythonConfigKey = 'useExistingPython'; export const notebookConfigKey = 'notebook'; +export const trustedBooksConfigKey = 'trustedBooks'; export const maxBookSearchDepth = 'maxBookSearchDepth'; export const winPlatform = 'win32'; @@ -33,6 +34,8 @@ export const localhostName = 'localhost'; export const localhostTitle = localize('managePackages.localhost', "localhost"); export const PackageNotFoundError = localize('managePackages.packageNotFound', "Could not find the specified package"); +export const visitedNotebooksMementoKey = 'notebooks.visited'; + export enum BuiltInCommands { SetContext = 'setContext' } diff --git a/extensions/notebook/src/common/localizedConstants.ts b/extensions/notebook/src/common/localizedConstants.ts index ce1a3d2940bb..e4e2fd3015a1 100644 --- a/extensions/notebook/src/common/localizedConstants.ts +++ b/extensions/notebook/src/common/localizedConstants.ts @@ -21,8 +21,12 @@ export const confirmReplace = localize('confirmReplace', "Folder already exists. export const openNotebookCommand = localize('openNotebookCommand', "Open Notebook"); export const openMarkdownCommand = localize('openMarkdownCommand', "Open Markdown"); export const openExternalLinkCommand = localize('openExternalLinkCommand', "Open External Link"); - +export const msgBookTrusted = localize('msgBookTrusted', "Book is now trusted in the workspace."); +export const msgBookAlreadyTrusted = localize('msgBookAlreadyTrusted', "Book is already trusted in this workspace."); +export const msgBookUntrusted = localize('msgBookUntrusted', "Book is no longer trusted in this workspace"); +export const msgBookAlreadyUntrusted = localize('msgBookAlreadyUntrusted', "Book is already untrusted in this workspace."); export const missingTocError = localize('bookInitializeFailed', "Failed to find a toc.yml."); + export function missingFileError(title: string): string { return localize('missingFileError', "Missing file : {0}", title); } export function invalidTocFileError(): string { return localize('InvalidError.tocFile', "Invalid toc file"); } export function invalidTocError(title: string): string { return localize('Invalid toc.yml', "Error: {0} has an incorrect toc.yml file", title); } diff --git a/extensions/notebook/src/extension.ts b/extensions/notebook/src/extension.ts index 284ba9f31507..9ba7072a22b2 100644 --- a/extensions/notebook/src/extension.ts +++ b/extensions/notebook/src/extension.ts @@ -36,6 +36,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openMarkdown', (resource) => bookTreeViewProvider.openMarkdown(resource))); extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openExternalLink', (resource) => bookTreeViewProvider.openExternalLink(resource))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.saveBook', () => untitledBookTreeViewProvider.saveJupyterBooks())); + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.trustBook', (resource) => bookTreeViewProvider.trustBook(resource))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.searchBook', (item) => bookTreeViewProvider.searchJupyterBooks(item))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.searchUntitledBook', () => untitledBookTreeViewProvider.searchJupyterBooks())); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.openBook', () => bookTreeViewProvider.openNewBook())); diff --git a/extensions/notebook/src/test/book/bookTrustManager.test.ts b/extensions/notebook/src/test/book/bookTrustManager.test.ts new file mode 100644 index 000000000000..976125a38dd6 --- /dev/null +++ b/extensions/notebook/src/test/book/bookTrustManager.test.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as should from 'should'; +import * as path from 'path'; +import * as TypeMoq from 'typemoq'; +import * as constants from '../../common/constants'; +import { IBookTrustManager, BookTrustManager } from '../../book/bookTrustManager'; +import { BookTreeItem, BookTreeItemFormat, BookTreeItemType } from '../../book/bookTreeItem'; +import { ApiWrapper } from '../../common/apiWrapper'; +import { WorkspaceConfiguration, ConfigurationTarget } from 'vscode'; +import { BookModel } from '../../book/bookModel'; + +describe('BookTrustManagerTests', function () { + + describe('TrustingInWorkspaces', () => { + let bookTrustManager: IBookTrustManager; + let trustedSubFolders: string[]; + let books: BookModel[]; + + beforeEach(() => { + trustedSubFolders = ['/SubFolder/']; + + // Mock Workspace Configuration + let workspaceConfigurtionMock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + workspaceConfigurtionMock.setup(config => config.get(TypeMoq.It.isValue(constants.trustedBooksConfigKey))).returns(() => [].concat(trustedSubFolders)); + workspaceConfigurtionMock.setup(config => config.update(TypeMoq.It.isValue(constants.trustedBooksConfigKey), TypeMoq.It.isAny(), TypeMoq.It.isValue(false))).returns((key: string, newValues: string[]) => { + trustedSubFolders.splice(0, trustedSubFolders.length, ...newValues); // Replace + return Promise.resolve(); + }); + + // Mock Api Wrapper + let apiWrapperMock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + + apiWrapperMock.setup(api => api.getWorkspaceFolders()).returns(() => [ + { + // @ts-ignore - Don't need all URI properties for this tests + uri: { + fsPath: '/temp/' + }, + }, + { + // @ts-ignore - Don't need all URI properties for this tests + uri: { + fsPath: '/temp2/' + } + }, + ]); + + apiWrapperMock.setup(api => api.getConfiguration(TypeMoq.It.isValue(constants.notebookConfigKey))).returns(() => workspaceConfigurtionMock.object); + + // Mock Book Data + let bookTreeItemFormat1: BookTreeItemFormat = { + root: '/temp/SubFolder/', + tableOfContents: { + sections: [ + { + url: path.join(path.sep, 'sample', 'notebook') + }, + { + url: path.join(path.sep, 'sample', 'notebook2') + } + ] + }, + isUntitled: undefined, + page: undefined, + title: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Book + }; + + let bookTreeItemFormat2: BookTreeItemFormat = { + root: '/temp/SubFolder2/', + tableOfContents: { + sections: [ + { + url: path.join(path.sep, 'sample', 'notebook') + } + ] + }, + isUntitled: undefined, + page: undefined, + title: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Book + }; + + let bookTreeItemFormat3: BookTreeItemFormat = { + root: '/temp2/SubFolder3/', + tableOfContents: { + sections: [ + { + url: path.join(path.sep, 'sample', 'notebook') + } + ] + }, + isUntitled: undefined, + page: undefined, + title: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Book + }; + + let bookModel1Mock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + bookModel1Mock.setup(model => model.bookItems).returns(() => [new BookTreeItem(bookTreeItemFormat1, undefined), new BookTreeItem(bookTreeItemFormat2, undefined)]); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep,'temp','SubFolder','content','sample', 'notebook.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep,'temp','SubFolder','content','sample', 'notebook2.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep,'temp','SubFolder2','content','sample', 'notebook.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isAnyString())).returns((uri: string) => undefined); + + let bookModel2Mock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + bookModel2Mock.setup(model => model.bookItems).returns(() => [new BookTreeItem(bookTreeItemFormat3, undefined)]); + bookModel2Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep,'temp2','SubFolder','content','sample','notebook.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel2Mock.setup(model => model.getNotebook(TypeMoq.It.isAnyString())).returns((uri: string) => undefined); + + books = [bookModel1Mock.object, bookModel2Mock.object]; + + bookTrustManager = new BookTrustManager(books, apiWrapperMock.object); + }); + + it('should trust notebooks in a trusted book within a workspace', async () => { + let notebookUri1 = path.join(path.sep,'temp','SubFolder','content','sample', 'notebook.ipynb'); + let notebookUri2 = path.join(path.sep,'temp','SubFolder','content','sample', 'notebook2.ipynb'); + + let isNotebook1Trusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri1); + let isNotebook2Trusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri2); + + should(isNotebook1Trusted).be.true("Notebook 1 should be trusted"); + should(isNotebook2Trusted).be.true("Notebook 2 should be trusted"); + + }); + + it('should NOT trust a notebook in an untrusted book within a workspace', async () => { + let notebookUri = path.join(path.sep,'temp','SubFolder2','content', 'sample', 'notebook.ipynb'); + let isNotebookTrusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrusted).be.false("Notebook should be trusted"); + }); + + it('should trust notebook after book has been trusted within a workspace', async () => { + let notebookUri = path.join(path.sep,'temp','SubFolder2','content', 'sample', 'notebook.ipynb'); + let isNotebookTrustedBeforeChange = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrustedBeforeChange).be.false("Notebook should NOT be trusted"); + + // add another book subfolder + bookTrustManager.setBookAsTrusted('/SubFolder2/'); + + let isNotebookTrustedAfterChange = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrustedAfterChange).be.true("Notebook should be trusted"); + }); + + it('should NOT trust a notebook when untrusting a book within a workspace', async () => { + let notebookUri = path.join(path.sep,'temp','SubFolder','content', 'sample', 'notebook.ipynb'); + let isNotebookTrusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrusted).be.true("Notebook should be trusted"); + + // remove trusted subfolders + trustedSubFolders = []; + + let isNotebookTrustedAfterChange = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrustedAfterChange).be.false("Notebook should not be trusted after book removal"); + }); + + it('should NOT trust an unknown book within a workspace', async () => { + let notebookUri = path.join(path.sep, 'randomfolder', 'randomsubfolder', 'content', 'randomnotebook.ipynb'); + let isNotebookTrusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrusted).be.false("Random notebooks should not be trusted"); + }); + + it('should NOT trust notebook inside trusted subfolder when absent in table of contents ', async () => { + bookTrustManager.setBookAsTrusted('/temp/SubFolder/'); + + let notebookUri = path.join(path.sep, 'temp', 'SubFolder', 'content', 'sample', 'notInToc.ipynb'); + let isNotebookTrusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrusted).be.false("Notebook should NOT be trusted"); + }); + }); + + describe('TrustingInFolder', () => { + + let bookTrustManager: IBookTrustManager; + let books: BookModel[]; + let trustedFolders: string[] = []; + + beforeEach(() => { + // Mock Workspace Configuration + let workspaceConfigurtionMock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + workspaceConfigurtionMock.setup(config => config.get(TypeMoq.It.isValue(constants.trustedBooksConfigKey))).returns(() => [].concat(trustedFolders)); + workspaceConfigurtionMock.setup(config => config.update(TypeMoq.It.isValue(constants.trustedBooksConfigKey), TypeMoq.It.isAny(), TypeMoq.It.isValue(ConfigurationTarget.Global))).returns((key: string, newValues: string[], target: ConfigurationTarget) => { + trustedFolders.splice(0, trustedFolders.length, ...newValues); // Replace + return Promise.resolve(); + }); + + + // Mock Api Wrapper + let apiWrapperMock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + + apiWrapperMock.setup(api => api.getWorkspaceFolders()).returns(() => []); + apiWrapperMock.setup(api => api.getConfiguration(TypeMoq.It.isValue(constants.notebookConfigKey))).returns(() => workspaceConfigurtionMock.object); + let bookTreeItemFormat1: BookTreeItemFormat = { + root: '/temp/SubFolder/', + tableOfContents: { + sections: [ + { + url: path.join(path.sep, 'sample', 'notebook') + }, + { + url: path.join(path.sep, 'sample', 'notebook2') + } + ] + }, + isUntitled: undefined, + page: undefined, + title: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Book + }; + + let bookTreeItemFormat2: BookTreeItemFormat = { + root: '/temp/SubFolder2/', + tableOfContents: { + sections: [ + { + url: path.join(path.sep, 'sample', 'notebook') + }, + { + url: path.join(path.sep, 'sample', 'notebook2') + } + ] + }, + isUntitled: undefined, + page: undefined, + title: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Book + }; + + let bookModel1Mock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + bookModel1Mock.setup(model => model.bookItems).returns(() => [new BookTreeItem(bookTreeItemFormat1, undefined)]); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep,'temp','SubFolder','content', 'sample', 'notebook.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep,'temp','SubFolder','content', 'sample', 'notebook2.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isAnyString())).returns((uri: string) => undefined); + + let bookModel2Mock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + bookModel2Mock.setup(model => model.bookItems).returns(() => [new BookTreeItem(bookTreeItemFormat2, undefined)]); + bookModel2Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep,'temp','SubFolder2','content', 'sample', 'notebook.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel2Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep,'temp','SubFolder2','content', 'sample', 'notebook2.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel2Mock.setup(model => model.getNotebook(TypeMoq.It.isAnyString())).returns((uri: string) => undefined); + + books = [bookModel1Mock.object, bookModel2Mock.object]; + + bookTrustManager = new BookTrustManager(books, apiWrapperMock.object); + }); + + it('should trust notebooks in a trusted book in a folder', async () => { + bookTrustManager.setBookAsTrusted('/temp/SubFolder/'); + + let notebookUri1 = path.join(path.sep,'temp','SubFolder','content', 'sample', 'notebook.ipynb'); + let notebookUri2 = path.join(path.sep,'temp','SubFolder','content', 'sample', 'notebook2.ipynb'); + + let isNotebook1Trusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri1); + let isNotebook2Trusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri2); + + should(isNotebook1Trusted).be.true("Notebook 1 should be trusted"); + should(isNotebook2Trusted).be.true("Notebook 2 should be trusted"); + + }); + + it('should NOT trust a notebook in an untrusted book in a folder', async () => { + let notebookUri = path.join(path.sep,'temp','SubFolder2','content', 'sample', 'notebook.ipynb'); + let isNotebookTrusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrusted).be.false("Notebook should be trusted"); + }); + + it('should trust notebook after book has been added to a folder', async () => { + let notebookUri = path.join(path.sep,'temp','SubFolder2','content', 'sample','notebook.ipynb'); + let isNotebookTrustedBeforeChange = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrustedBeforeChange).be.false("Notebook should NOT be trusted"); + + bookTrustManager.setBookAsTrusted('/temp/SubFolder2/'); + + let isNotebookTrustedAfterChange = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrustedAfterChange).be.true("Notebook should be trusted"); + }); + + it('should NOT trust a notebook when untrusting a book in folder', async () => { + bookTrustManager.setBookAsTrusted('/temp/SubFolder/'); + let notebookUri = path.join(path.sep,'temp','SubFolder','content', 'sample', 'notebook.ipynb'); + let isNotebookTrusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrusted).be.true("Notebook should be trusted"); + + trustedFolders = []; + + let isNotebookTrustedAfterChange = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrustedAfterChange).be.false("Notebook should not be trusted after book removal"); + }); + + it('should NOT trust an unknown book', async () => { + let notebookUri = path.join(path.sep, 'randomfolder', 'randomsubfolder', 'content', 'randomnotebook.ipynb'); + let isNotebookTrusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrusted).be.false("Random notebooks should not be trusted"); + }); + + it('should NOT trust notebook inside trusted subfolder when absent in table of contents ', async () => { + bookTrustManager.setBookAsTrusted('/temp/SubFolder/'); + + let notebookUri = path.join(path.sep, 'temp', 'SubFolder', 'content', 'sample', 'notInToc.ipynb'); + let isNotebookTrusted = bookTrustManager.isNotebookTrustedByDefault(notebookUri); + + should(isNotebookTrusted).be.false("Notebook should NOT be trusted"); + }); + + }); +}); diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 89e68738aa11..6d54bf77b303 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -29,6 +29,14 @@ declare module 'azdata' { export function getConnection(uri: string): Thenable; } + export namespace nb { + export interface NotebookDocument { + /** + * Sets the trust mode for the notebook document. + */ + setTrusted(state: boolean); + } + } export type SqlDbType = 'BigInt' | 'Binary' | 'Bit' | 'Char' | 'DateTime' | 'Decimal' | 'Float' | 'Image' | 'Int' | 'Money' | 'NChar' | 'NText' | 'NVarChar' | 'Real' diff --git a/src/sql/workbench/api/browser/mainThreadNotebookDocumentsAndEditors.ts b/src/sql/workbench/api/browser/mainThreadNotebookDocumentsAndEditors.ts index d40bbaeb5466..189b99474279 100644 --- a/src/sql/workbench/api/browser/mainThreadNotebookDocumentsAndEditors.ts +++ b/src/sql/workbench/api/browser/mainThreadNotebookDocumentsAndEditors.ts @@ -362,6 +362,11 @@ export class MainThreadNotebookDocumentsAndEditors extends Disposable implements return Promise.resolve(this.doOpenEditor(resource, options)); } + $trySetTrusted(uriComponent: UriComponents, isTrusted: boolean): Promise { + let uri = URI.revive(uriComponent); + return this._notebookService.setTrusted(uri, isTrusted); + } + $tryApplyEdits(id: string, modelVersionId: number, edits: ISingleNotebookEditOperation[], opts: IUndoStopOptions): Promise { let editor = this.getEditor(id); if (!editor) { diff --git a/src/sql/workbench/api/common/extHostNotebookDocumentData.ts b/src/sql/workbench/api/common/extHostNotebookDocumentData.ts index 29621f8f16ff..f53793856b5c 100644 --- a/src/sql/workbench/api/common/extHostNotebookDocumentData.ts +++ b/src/sql/workbench/api/common/extHostNotebookDocumentData.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as azdata from 'azdata'; - import { IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { ok } from 'vs/base/common/assert'; @@ -50,6 +49,7 @@ export class ExtHostNotebookDocumentData implements IDisposable { get cells() { return data._cells; }, get kernelSpec() { return data._kernelSpec; }, save() { return data._save(); }, + setTrusted(isTrusted) { data._setTrusted(isTrusted); }, validateCellRange(range) { return data._validateRange(range); }, }; } @@ -61,7 +61,13 @@ export class ExtHostNotebookDocumentData implements IDisposable { return Promise.reject(new Error('Document has been closed')); } return this._proxy.$trySaveDocument(this._uri); + } + private _setTrusted(isTrusted: boolean): Thenable { + if (this._isDisposed) { + return Promise.reject(new Error('Document has been closed')); + } + return this._proxy.$trySetTrusted(this._uri, isTrusted); } public onModelChanged(data: INotebookModelChangedData) { diff --git a/src/sql/workbench/api/common/extHostNotebookEditor.ts b/src/sql/workbench/api/common/extHostNotebookEditor.ts index 45e611acb73f..80fa6fdcbb67 100644 --- a/src/sql/workbench/api/common/extHostNotebookEditor.ts +++ b/src/sql/workbench/api/common/extHostNotebookEditor.ts @@ -82,6 +82,10 @@ export class NotebookEditorEdit { return range; } + setTrusted(isTrusted: boolean) { + this._document.setTrusted(isTrusted); + } + insertCell(value: Partial, index?: number, collapsed?: boolean): void { if (index === null || index === undefined) { // If not specified, assume adding to end of list diff --git a/src/sql/workbench/api/common/sqlExtHost.protocol.ts b/src/sql/workbench/api/common/sqlExtHost.protocol.ts index 880478bf0388..3c526d8b429f 100644 --- a/src/sql/workbench/api/common/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/common/sqlExtHost.protocol.ts @@ -890,6 +890,7 @@ export interface ExtHostNotebookDocumentsAndEditorsShape { } export interface MainThreadNotebookDocumentsAndEditorsShape extends IDisposable { + $trySetTrusted(_uri: UriComponents, isTrusted: boolean): Thenable; $trySaveDocument(uri: UriComponents): Thenable; $tryShowNotebookDocument(resource: UriComponents, options: INotebookShowOptions): Promise; $tryApplyEdits(id: string, modelVersionId: number, edits: ISingleNotebookEditOperation[], opts: IUndoStopOptions): Promise; diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/linkHandler.directive.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/linkHandler.directive.ts index 4c19aad3eecf..a02a8218bdda 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/linkHandler.directive.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/linkHandler.directive.ts @@ -58,7 +58,7 @@ export class LinkHandlerDirective { // ignore } if (uri && this.openerService && this.isSupportedLink(uri)) { - if (uri.fragment && uri.fragment.length > 0 && uri.fsPath === this.workbenchFilePath.fsPath) { + if (uri.fragment && uri.fragment.length > 0 && uri.path === this.workbenchFilePath.path) { this.notebookService.navigateTo(this.notebookUri, uri.fragment); } else { this.openerService.open(uri).catch(onUnexpectedError); diff --git a/src/sql/workbench/contrib/notebook/test/stubs.ts b/src/sql/workbench/contrib/notebook/test/stubs.ts index c980e754356a..47fbf0ecd269 100644 --- a/src/sql/workbench/contrib/notebook/test/stubs.ts +++ b/src/sql/workbench/contrib/notebook/test/stubs.ts @@ -205,6 +205,9 @@ export class NotebookServiceStub implements INotebookService { get languageMagics(): ILanguageMagic[] { throw new Error('Method not implemented.'); } + setTrusted(notebookUri: URI, isTrusted: boolean): Promise { + throw new Error('Method not implemented.'); + } registerProvider(providerId: string, provider: INotebookProvider): void { throw new Error('Method not implemented.'); } diff --git a/src/sql/workbench/services/notebook/browser/models/notebookModel.ts b/src/sql/workbench/services/notebook/browser/models/notebookModel.ts index 4caa1832458d..33848c003144 100644 --- a/src/sql/workbench/services/notebook/browser/models/notebookModel.ts +++ b/src/sql/workbench/services/notebook/browser/models/notebookModel.ts @@ -233,6 +233,7 @@ export class NotebookModel extends Disposable implements INotebookModel { public set trustedMode(isTrusted: boolean) { this._trustedMode = isTrusted; + if (this._cells) { this._cells.forEach(c => { c.trustedMode = this._trustedMode; @@ -290,6 +291,7 @@ export class NotebookModel extends Disposable implements INotebookModel { public async loadContents(isTrusted: boolean = false): Promise { try { this._trustedMode = isTrusted; + let contents = null; if (this._notebookOptions && this._notebookOptions.contentManager) { diff --git a/src/sql/workbench/services/notebook/browser/notebookService.ts b/src/sql/workbench/services/notebook/browser/notebookService.ts index ed4932bc671e..db1eb215e0c4 100644 --- a/src/sql/workbench/services/notebook/browser/notebookService.ts +++ b/src/sql/workbench/services/notebook/browser/notebookService.ts @@ -112,6 +112,13 @@ export interface INotebookService { * @param sectionId ID of the section to navigate to */ navigateTo(notebookUri: URI, sectionId: string): void; + + /** + * Sets the trusted mode for the sepcified notebook. + * @param notebookUri URI of the notebook to navigate to + * @param isTrusted True if notebook is to be set to trusted, false otherwise. + */ + setTrusted(notebookUri: URI, isTrusted: boolean): Promise; } export interface INotebookProvider { diff --git a/src/sql/workbench/services/notebook/browser/notebookServiceImpl.ts b/src/sql/workbench/services/notebook/browser/notebookServiceImpl.ts index 4c39089cdbd1..bfa7c0c1b681 100644 --- a/src/sql/workbench/services/notebook/browser/notebookServiceImpl.ts +++ b/src/sql/workbench/services/notebook/browser/notebookServiceImpl.ts @@ -606,4 +606,25 @@ export class NotebookService extends Disposable implements INotebookService { editor.navigateToSection(sectionId); } } + + /** + * Trusts a notebook with the specified URI. + * @param notebookUri The notebook URI to set the trusted mode for. + * @param isTrusted True if the notebook is to be trusted, false otherwise. + */ + async setTrusted(notebookUri: URI, isTrusted: boolean): Promise { + let editor = this.findNotebookEditor(notebookUri); + + if (editor && editor.model) { + if (isTrusted) { + this._trustedCacheQueue.push(notebookUri); + } else { + this._unTrustedCacheQueue.push(notebookUri); + } + await this.updateTrustedCache(); + editor.model.trustedMode = isTrusted; + } + + return isTrusted; + } }