Skip to content

Commit fbd7ab9

Browse files
authored
fix: prevent duplicate VSIX installations by using package.json identity (#16963)
Resolves GH-16845 - Extract extension identity (publisher, name, version) from the VSIX's embedded package.json instead of deriving it from the filename - Check already-deployed plugins via `getDeployedPluginsById` to block duplicate installations - Fall back to filename-based ID if package.json cannot be read
1 parent 583e8b7 commit fbd7ab9

File tree

2 files changed

+86
-7
lines changed

2 files changed

+86
-7
lines changed

packages/plugin-ext-vscode/src/node/local-vsix-file-plugin-deployer-resolver.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,19 @@
1616

1717
import * as path from 'path';
1818
import { inject, injectable } from '@theia/core/shared/inversify';
19-
import { PluginDeployerResolverContext } from '@theia/plugin-ext';
19+
import { PluginDeployerResolverContext, PluginDeployerHandler, PluginIdentifiers } from '@theia/plugin-ext';
2020
import { LocalPluginDeployerResolver } from '@theia/plugin-ext/lib/main/node/resolvers/local-plugin-deployer-resolver';
2121
import { PluginVSCodeEnvironment } from '../common/plugin-vscode-environment';
2222
import { isVSCodePluginFile } from './plugin-vscode-file-handler';
23-
import { existsInDeploymentDir, unpackToDeploymentDir } from './plugin-vscode-utils';
23+
import { existsInDeploymentDir, unpackToDeploymentDir, extractExtensionIdentityFromVsix } from './plugin-vscode-utils';
2424

2525
@injectable()
2626
export class LocalVSIXFilePluginDeployerResolver extends LocalPluginDeployerResolver {
2727
static LOCAL_FILE = 'local-file';
2828
static FILE_EXTENSION = '.vsix';
2929

3030
@inject(PluginVSCodeEnvironment) protected readonly environment: PluginVSCodeEnvironment;
31+
@inject(PluginDeployerHandler) protected readonly pluginDeployerHandler: PluginDeployerHandler;
3132

3233
protected get supportedScheme(): string {
3334
return LocalVSIXFilePluginDeployerResolver.LOCAL_FILE;
@@ -38,14 +39,48 @@ export class LocalVSIXFilePluginDeployerResolver extends LocalPluginDeployerReso
3839
}
3940

4041
async resolveFromLocalPath(pluginResolverContext: PluginDeployerResolverContext, localPath: string): Promise<void> {
41-
const extensionId = path.basename(localPath, LocalVSIXFilePluginDeployerResolver.FILE_EXTENSION);
42+
// Extract the true extension identity from the VSIX package.json
43+
// This prevents duplicate installations when the same extension is installed from VSIX files with different filenames
44+
// See: https://github.com/eclipse-theia/theia/issues/16845
45+
const components = await extractExtensionIdentityFromVsix(localPath);
4246

43-
if (await existsInDeploymentDir(this.environment, extensionId)) {
44-
console.log(`[${pluginResolverContext.getOriginId()}]: Target dir already exists in plugin deployment dir`);
47+
if (!components) {
48+
// Fallback to filename-based ID if package.json cannot be read
49+
// This maintains backward compatibility for edge cases
50+
const fallbackId = path.basename(localPath, LocalVSIXFilePluginDeployerResolver.FILE_EXTENSION);
51+
console.warn(`[${pluginResolverContext.getOriginId()}]: Could not read extension identity from VSIX, falling back to filename: ${fallbackId}`);
52+
53+
if (await existsInDeploymentDir(this.environment, fallbackId)) {
54+
console.log(`[${pluginResolverContext.getOriginId()}]: Target dir already exists in plugin deployment dir`);
55+
return;
56+
}
57+
58+
const fallbackDeploymentDir = await unpackToDeploymentDir(this.environment, localPath, fallbackId);
59+
pluginResolverContext.addPlugin(fallbackId, fallbackDeploymentDir);
60+
return;
61+
}
62+
63+
const unversionedId = PluginIdentifiers.componentsToUnversionedId(components);
64+
const versionedId = PluginIdentifiers.componentsToVersionedId(components);
65+
66+
// Check if an extension with this identity is already deployed in memory
67+
const existingPlugins = this.pluginDeployerHandler.getDeployedPluginsById(unversionedId);
68+
if (existingPlugins.length > 0) {
69+
const existingVersions = existingPlugins.map(p => p.metadata.model.version);
70+
console.log(
71+
'Extension ' + unversionedId + ' (version(s): ' + existingVersions.join(', ') + ') is already installed.\n' +
72+
'Uninstall the existing extension before installing a new version from VSIX.'
73+
);
74+
return;
75+
}
76+
77+
// Check if the deployment directory already exists on disk
78+
if (await existsInDeploymentDir(this.environment, versionedId)) {
79+
console.log(`[${pluginResolverContext.getOriginId()}]: Extension "${versionedId}" already exists in deployment dir`);
4580
return;
4681
}
4782

48-
const extensionDeploymentDir = await unpackToDeploymentDir(this.environment, localPath, extensionId);
49-
pluginResolverContext.addPlugin(extensionId, extensionDeploymentDir);
83+
const extensionDeploymentDir = await unpackToDeploymentDir(this.environment, localPath, versionedId);
84+
pluginResolverContext.addPlugin(versionedId, extensionDeploymentDir);
5085
}
5186
}

packages/plugin-ext-vscode/src/node/plugin-vscode-utils.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,50 @@ import * as filenamify from 'filenamify';
2020
import { FileUri } from '@theia/core/lib/node';
2121
import * as fs from '@theia/core/shared/fs-extra';
2222
import { PluginVSCodeEnvironment } from '../common/plugin-vscode-environment';
23+
import { PluginIdentifiers, PluginPackage } from '@theia/plugin-ext/lib/common/plugin-protocol';
24+
25+
/**
26+
* Extracts extension identity from a VSIX file by reading its package.json.
27+
*
28+
* VSIX files are ZIP archives with the extension content in an `extension/` subdirectory.
29+
* This function extracts only the `extension/package.json` file to read the extension metadata.
30+
*
31+
* @param vsixPath Path to the VSIX file
32+
* @returns PluginIdentifiers.Components (publisher?, name, version), or undefined if the package.json cannot be read or is invalid
33+
*/
34+
export async function extractExtensionIdentityFromVsix(vsixPath: string): Promise<PluginIdentifiers.Components | undefined> {
35+
try {
36+
// Extract only the package.json file from the VSIX
37+
const files = await decompress(vsixPath, {
38+
filter: file => file.path === 'extension/package.json'
39+
});
40+
41+
if (files.length === 0) {
42+
console.warn(`[${vsixPath}]: No extension/package.json found in VSIX`);
43+
return undefined;
44+
}
45+
46+
const packageJsonContent = files[0].data.toString('utf8');
47+
const packageJson: Partial<PluginPackage> = JSON.parse(packageJsonContent);
48+
49+
// Validate required fields
50+
if (!packageJson.name || !packageJson.version) {
51+
console.warn(`[${vsixPath}]: Invalid package.json - missing name or version`);
52+
return undefined;
53+
}
54+
55+
const components: PluginIdentifiers.Components = {
56+
publisher: packageJson.publisher,
57+
name: packageJson.name,
58+
version: packageJson.version
59+
};
60+
61+
return components;
62+
} catch (error) {
63+
console.error(`[${vsixPath}]: Failed to extract extension identity from VSIX`, error);
64+
return undefined;
65+
}
66+
}
2367

2468
export async function decompressExtension(sourcePath: string, destPath: string): Promise<boolean> {
2569
try {

0 commit comments

Comments
 (0)