Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement safe plugin uninstallation #11084

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,5 @@
"editor.rulers": [
180
],
"typescript.preferences.quoteStyle": "single",
}
27 changes: 13 additions & 14 deletions packages/core/src/common/promise-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,21 @@ import { CancellationToken, CancellationError, cancelled } from './cancellation'
export class Deferred<T = void> {
state: 'resolved' | 'rejected' | 'unresolved' = 'unresolved';
resolve: (value: T | PromiseLike<T>) => void;
Copy link
Member

@paul-marechal paul-marechal Jun 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that Deferred should support resolving to promises, but this introduces a bug where we may resolve to an unresolved PromiseLike but we will mark the Deferred as resolved too early.

Consider the following change:

export class Deferred<T = void> {
    state: 'resolved' | 'rejected' | 'unresolved' = 'unresolved';
    resolve: (value: T | PromiseLike<T>) => void;
    reject: (reason?: unknown) => void;

    promise: Promise<T> = new Promise<T>((resolve, reject) => {
        this.resolve = resolve;
        this.reject = reject;
    }).then(
        value => { this.state = 'resolved'; return value; },
        error => { this.state = 'rejected'; throw error; }
    );
}

Copy link
Member

@paul-marechal paul-marechal Jun 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand I could be misunderstanding the role of the state property?

Is it meant to represent when resolve has been called, or when the underlying promise resolves? Doesn't look obvious.

reject: (err?: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
reject: (err?: unknown) => void;

promise = new Promise<T>((resolve, reject) => {
this.resolve = result => {
resolve(result);
if (this.state === 'unresolved') {
this.state = 'resolved';
}
};
this.reject = err => {
reject(err);
if (this.state === 'unresolved') {
this.state = 'rejected';
}
};
});
this.resolve = resolve;
this.reject = reject;
}).then(
res => (this.setState('resolved'), res),
err => (this.setState('rejected'), Promise.reject(err)),
);

protected setState(state: 'resolved' | 'rejected'): void {
if (this.state === 'unresolved') {
this.state = state;
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { CustomEditorOpener } from '@theia/plugin-ext/lib/main/browser/custom-ed
import { nls } from '@theia/core/lib/common/nls';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import * as monaco from '@theia/monaco-editor-core';
import { VSCodeExtensionUri } from '../common/plugin-vscode-uri';

export namespace VscodeCommands {
export const OPEN: Command = {
Expand Down Expand Up @@ -305,7 +306,7 @@ export class PluginVscodeCommandsContribution implements CommandContribution {
commands.registerCommand({ id: VscodeCommands.INSTALL_FROM_VSIX.id }, {
execute: async (vsixUriOrExtensionId: TheiaURI | UriComponents | string) => {
if (typeof vsixUriOrExtensionId === 'string') {
await this.pluginServer.deploy(`vscode:extension/${vsixUriOrExtensionId}`);
await this.pluginServer.deploy(VSCodeExtensionUri.toVsxExtensionUriString(vsixUriOrExtensionId));
} else {
const uriPath = isUriComponents(vsixUriOrExtensionId) ? URI.revive(vsixUriOrExtensionId).fsPath : await this.fileService.fsPath(vsixUriOrExtensionId);
await this.pluginServer.deploy(`local-file:${uriPath}`);
Expand Down
41 changes: 41 additions & 0 deletions packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// *****************************************************************************
// Copyright (C) 2022 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
// *****************************************************************************

import URI from '@theia/core/lib/common/uri';

/**
* Static methods for identifying a plugin as the target of the VSCode deployment system.
* In practice, this means that it will be resolved and deployed by the Open-VSX system.
*/
export namespace VSCodeExtensionUri {
export const VSCODE_PREFIX = 'vscode:extension/';
/**
* Should be used to prefix a plugin's ID to ensure that it is identified as a VSX Extension.
* @returns `vscode:extension/${id}`
*/
export function toVsxExtensionUriString(id: string): string {
return `${VSCODE_PREFIX}${id}`;
}
export function toUri(id: string): URI {
return new URI(toVsxExtensionUriString(id));
}
export function toId(uri: URI): string | undefined {
if (uri.scheme === 'vscode' && uri.path.dir.toString() === 'extension') {
return uri.path.base;
}
return undefined;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,70 @@
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
// *****************************************************************************

import * as fs from '@theia/core/shared/fs-extra';
import * as path from 'path';
import { injectable } from '@theia/core/shared/inversify';
import * as filenamify from 'filenamify';
import * as fs from '@theia/core/shared/fs-extra';
import { inject, injectable } from '@theia/core/shared/inversify';
import { RecursivePartial } from '@theia/core';
import {
PluginDeployerDirectoryHandler,
PluginDeployerEntry, PluginDeployerDirectoryHandlerContext,
PluginDeployerEntryType, PluginPackage
PluginDeployerDirectoryHandler, PluginDeployerEntry, PluginDeployerDirectoryHandlerContext,
PluginDeployerEntryType, PluginPackage, PluginType, PluginIdentifiers
} from '@theia/plugin-ext';
import { FileUri } from '@theia/core/lib/node';
import { getTempDir } from '@theia/plugin-ext/lib/main/node/temp-dir-util';
import { PluginCliContribution } from '@theia/plugin-ext/lib/main/node/plugin-cli-contribution';

@injectable()
export class PluginVsCodeDirectoryHandler implements PluginDeployerDirectoryHandler {

protected readonly deploymentDirectory = FileUri.create(getTempDir('vscode-copied'));

@inject(PluginCliContribution) protected readonly pluginCli: PluginCliContribution;

accept(plugin: PluginDeployerEntry): boolean {
console.debug(`Resolving "${plugin.id()}" as a VS Code extension...`);
return this.resolvePackage(plugin) || this.resolveFromSources(plugin) || this.resolveFromVSIX(plugin) || this.resolveFromNpmTarball(plugin);
return this.attemptResolution(plugin);
}

protected attemptResolution(plugin: PluginDeployerEntry): boolean {
return this.resolvePackage(plugin) || this.deriveMetadata(plugin);
}

protected deriveMetadata(plugin: PluginDeployerEntry): boolean {
return this.resolveFromSources(plugin) || this.resolveFromVSIX(plugin) || this.resolveFromNpmTarball(plugin);
}

async handle(context: PluginDeployerDirectoryHandlerContext): Promise<void> {
await this.copyDirectory(context);
context.pluginEntry().accept(PluginDeployerEntryType.BACKEND);
}

protected async copyDirectory(context: PluginDeployerDirectoryHandlerContext): Promise<void> {
if (this.pluginCli.copyUncompressedPlugins() && context.pluginEntry().type === PluginType.User) {
const entry = context.pluginEntry();
const id = entry.id();
const pathToRestore = entry.path();
const origin = entry.originalPath();
const targetDir = await this.getExtensionDir(context);
try {
if (fs.existsSync(targetDir) || !entry.path().startsWith(origin)) {
console.log(`[${id}]: already copied.`);
} else {
console.log(`[${id}]: copying to "${targetDir}"`);
await fs.mkdirp(FileUri.fsPath(this.deploymentDirectory));
await context.copy(origin, targetDir);
entry.updatePath(targetDir);
if (!this.deriveMetadata(entry)) {
throw new Error('Unable to resolve plugin metadata after copying');
}
}
} catch (e) {
console.warn(`[${id}]: Error when copying.`, e);
entry.updatePath(pathToRestore);
}
}
}

protected resolveFromSources(plugin: PluginDeployerEntry): boolean {
const pluginPath = plugin.path();
return this.resolvePackage(plugin, { pluginPath, pck: this.requirePackage(pluginPath) });
Expand Down Expand Up @@ -65,6 +107,7 @@ export class PluginVsCodeDirectoryHandler implements PluginDeployerDirectoryHand
if (!pck || !pck.name || !pck.version || !pck.engines || !pck.engines.vscode) {
return false;
}
pck.publisher ??= PluginIdentifiers.UNPUBLISHED;
if (options) {
plugin.storeValue('package.json', pck);
plugin.rootPath = plugin.path();
Expand All @@ -76,10 +119,15 @@ export class PluginVsCodeDirectoryHandler implements PluginDeployerDirectoryHand

protected requirePackage(pluginPath: string): PluginPackage | undefined {
try {
return fs.readJSONSync(path.join(pluginPath, 'package.json'));
const plugin = fs.readJSONSync(path.join(pluginPath, 'package.json')) as PluginPackage;
plugin.publisher ??= PluginIdentifiers.UNPUBLISHED;
return plugin;
} catch {
return undefined;
}
}

protected async getExtensionDir(context: PluginDeployerDirectoryHandlerContext): Promise<string> {
return FileUri.fsPath(this.deploymentDirectory.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' })));
}
}
26 changes: 23 additions & 3 deletions packages/plugin-ext-vscode/src/node/plugin-vscode-file-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { injectable, inject } from '@theia/core/shared/inversify';
import { getTempDir } from '@theia/plugin-ext/lib/main/node/temp-dir-util';
import { PluginVSCodeEnvironment } from '../common/plugin-vscode-environment';
import { FileUri } from '@theia/core/lib/node/file-uri';
import URI from '@theia/core/lib/common/uri';

@injectable()
export class PluginVsCodeFileHandler implements PluginDeployerFileHandler {
Expand All @@ -40,6 +41,7 @@ export class PluginVsCodeFileHandler implements PluginDeployerFileHandler {
}

async handle(context: PluginDeployerFileHandlerContext): Promise<void> {
await this.ensureDiscoverability(context);
const id = context.pluginEntry().id();
const extensionDir = await this.getExtensionDir(context);
console.log(`[${id}]: trying to decompress into "${extensionDir}"...`);
Expand All @@ -54,11 +56,29 @@ export class PluginVsCodeFileHandler implements PluginDeployerFileHandler {
}

protected async getExtensionDir(context: PluginDeployerFileHandlerContext): Promise<string> {
let extensionsDirUri = this.systemExtensionsDirUri;
return FileUri.fsPath(this.systemExtensionsDirUri.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' })));
}

/**
* Ensures that a user-installed plugin file is transferred to the user extension folder.
*/
protected async ensureDiscoverability(context: PluginDeployerFileHandlerContext): Promise<void> {
if (context.pluginEntry().type === PluginType.User) {
extensionsDirUri = await this.environment.getExtensionsDirUri();
const userExtensionsDir = await this.environment.getExtensionsDirUri();
const currentPath = context.pluginEntry().path();
if (!userExtensionsDir.isEqualOrParent(new URI(currentPath)) && !userExtensionsDir.isEqualOrParent(new URI(context.pluginEntry().originalPath()))) {
try {
const newPath = FileUri.fsPath(userExtensionsDir.resolve(path.basename(currentPath)));
await fs.mkdirp(FileUri.fsPath(userExtensionsDir));
await new Promise<void>((resolve, reject) => {
fs.copyFile(currentPath, newPath, error => error ? reject(error) : resolve());
});
context.pluginEntry().updatePath(newPath);
} catch (e) {
console.error(`[${context.pluginEntry().id}]: Failed to copy to user directory. Future sessions may not have access to this plugin.`);
}
}
}
return FileUri.fsPath(extensionsDirUri.resolve(filenamify(context.pluginEntry().id(), { replacement: '_' })));
}

protected async decompress(extensionDir: string, context: PluginDeployerFileHandlerContext): Promise<void> {
Expand Down
8 changes: 4 additions & 4 deletions packages/plugin-ext-vscode/src/node/scanner-vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,25 @@

import * as path from 'path';
import { injectable } from '@theia/core/shared/inversify';
import { PluginScanner, PluginEngine, PluginPackage, PluginModel, PluginLifecycle, PluginEntryPoint, buildFrontendModuleName, UIKind } from '@theia/plugin-ext';
import { PluginScanner, PluginEngine, PluginPackage, PluginModel, PluginLifecycle, PluginEntryPoint, buildFrontendModuleName, UIKind, PluginIdentifiers } from '@theia/plugin-ext';
import { TheiaPluginScanner } from '@theia/plugin-ext/lib/hosted/node/scanners/scanner-theia';
import { environment } from '@theia/core/shared/@theia/application-package/lib/environment';
import { VSCodeExtensionUri } from '../common/plugin-vscode-uri';

const uiKind = environment.electron.is() ? UIKind.Desktop : UIKind.Web;

@injectable()
export class VsCodePluginScanner extends TheiaPluginScanner implements PluginScanner {

private readonly VSCODE_TYPE: PluginEngine = 'vscode';
private readonly VSCODE_PREFIX: string = 'vscode:extension/';

override get apiType(): PluginEngine {
return this.VSCODE_TYPE;
}

override getModel(plugin: PluginPackage): PluginModel {
// publisher can be empty on vscode extension development
const publisher = plugin.publisher || '';
const publisher = plugin.publisher ?? PluginIdentifiers.UNPUBLISHED;

// Only one entrypoint is valid in vscode extensions
// Mimic choosing frontend (web extension) and backend (local/remote extension) as described here:
Expand Down Expand Up @@ -86,7 +86,7 @@ export class VsCodePluginScanner extends TheiaPluginScanner implements PluginSca
// Iterate over the list of dependencies present, and add them to the collection.
dependency.forEach((dep: string) => {
const dependencyId = dep.toLowerCase();
dependencies.set(dependencyId, this.VSCODE_PREFIX + dependencyId);
dependencies.set(dependencyId, VSCodeExtensionUri.toVsxExtensionUriString(dependencyId));
});
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-ext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"mime": "^2.4.4",
"ps-tree": "^1.2.0",
"request": "^2.82.0",
"semver": "^5.4.1",
"uuid": "^8.0.0",
"vhost": "^3.0.2",
"vscode-debugprotocol": "^1.32.0",
Expand Down
84 changes: 84 additions & 0 deletions packages/plugin-ext/src/common/plugin-identifiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// *****************************************************************************
// Copyright (C) 2022 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
// *****************************************************************************

export namespace PluginIdentifiers {
export interface Components {
publisher?: string;
name: string;
version: string;
}

export interface IdAndVersion {
id: UnversionedId;
version: string;
}

export type VersionedId = `${string}.${string}@${string}`;
export type UnversionedId = `${string}.${string}`;
/** Unpublished plugins (not from Open VSX or VSCode plugin store) may not have a `publisher` field. */
export const UNPUBLISHED = '<unpublished>';

/**
* @returns a string in the format `<publisher>.<name>`
*/
export function componentsToUnversionedId({ publisher = UNPUBLISHED, name }: Components): UnversionedId {
return `${publisher.toLowerCase()}.${name.toLowerCase()}`;
}
/**
* @returns a string in the format `<publisher>.<name>@<version>`.
*/
export function componentsToVersionedId({ publisher = UNPUBLISHED, name, version }: Components): VersionedId {
return `${publisher.toLowerCase()}.${name.toLowerCase()}@${version}`;
}
export function componentsToVersionWithId(components: Components): IdAndVersion {
return { id: componentsToUnversionedId(components), version: components.version };
}
/**
* @returns a string in the format `<id>@<version>`.
*/
export function idAndVersionToVersionedId({ id, version }: IdAndVersion): VersionedId {
return `${id}@${version}`;
}
/**
* @returns a string in the format `<publisher>.<name>`.
*/
export function unversionedFromVersioned(id: VersionedId): UnversionedId {
const endOfId = id.indexOf('@');
return id.slice(0, endOfId) as UnversionedId;
}
/**
* @returns `undefined` if it looks like the string passed in does not have the format returned by {@link PluginIdentifiers.toVersionedId}.
*/
export function identifiersFromVersionedId(probablyId: string): Components | undefined {
const endOfPublisher = probablyId.indexOf('.');
const endOfName = probablyId.indexOf('@', endOfPublisher);
if (endOfPublisher === -1 || endOfName === -1) {
return undefined;
}
return { publisher: probablyId.slice(0, endOfPublisher), name: probablyId.slice(endOfPublisher + 1, endOfName), version: probablyId.slice(endOfName + 1) };
}
/**
* @returns `undefined` if it looks like the string passed in does not have the format returned by {@link PluginIdentifiers.toVersionedId}.
*/
export function idAndVersionFromVersionedId(probablyId: string): IdAndVersion | undefined {
const endOfPublisher = probablyId.indexOf('.');
const endOfName = probablyId.indexOf('@', endOfPublisher);
if (endOfPublisher === -1 || endOfName === -1) {
return undefined;
}
return { id: probablyId.slice(0, endOfName) as UnversionedId, version: probablyId.slice(endOfName + 1) };
}
}
Loading