Skip to content

Commit 97ef236

Browse files
committed
Validate vsix urls in gitpod.yaml
1 parent e1ad766 commit 97ef236

File tree

8 files changed

+536
-35
lines changed

8 files changed

+536
-35
lines changed

extensions/gitpod-web/package.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -502,12 +502,18 @@
502502
},
503503
"devDependencies": {
504504
"@types/node": "^10.12.21",
505-
"@types/node-fetch": "^2.5.8"
505+
"@types/node-fetch": "^2.5.12",
506+
"@types/uuid": "8.0.0",
507+
"@types/yauzl": "^2.9.1",
508+
"@types/yazl": "^2.4.2"
506509
},
507510
"dependencies": {
508511
"gitpod-shared": "0.0.1",
509-
"node-fetch": "^2.6.1",
512+
"node-fetch": "^2.6.5",
513+
"uuid": "8.1.0",
510514
"vscode-jsonrpc": "^5.0.1",
511-
"vscode-nls": "^5.0.0"
515+
"vscode-nls": "^5.0.0",
516+
"yauzl": "^2.9.2",
517+
"yazl": "^2.4.3"
512518
}
513519
}

extensions/gitpod-web/src/extension.ts

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import * as workspaceInstance from '@gitpod/gitpod-protocol/lib/workspace-instance';
99
import * as grpc from '@grpc/grpc-js';
1010
import * as fs from 'fs';
11+
import * as os from 'os';
12+
import * as uuid from 'uuid';
1113
import { GitpodPluginModel, GitpodExtensionContext, setupGitpodContext, registerTasks, registerIpcHookCli } from 'gitpod-shared';
1214
import { GetTokenRequest } from '@gitpod/supervisor-api-grpc/lib/token_pb';
1315
import { PortsStatus, ExposedPortInfo, PortsStatusRequest, PortsStatusResponse, PortAutoExposure, PortVisibility, OnPortExposedAction } from '@gitpod/supervisor-api-grpc/lib/status_pb';
@@ -19,7 +21,9 @@ import * as path from 'path';
1921
import { URL } from 'url';
2022
import * as util from 'util';
2123
import * as vscode from 'vscode';
22-
import { ThrottledDelayer } from './async';
24+
import { ThrottledDelayer } from './util/async';
25+
import { download } from './util/download';
26+
import { getManifest } from './util/extensionManagmentUtill';
2327

2428
let gitpodContext: GitpodExtensionContext | undefined;
2529
export async function activate(context: vscode.ExtensionContext) {
@@ -185,7 +189,7 @@ export function registerAuth(context: GitpodExtensionContext): void {
185189
if (!userResponse.ok) {
186190
throw new Error(`Getting GitHub account info failed: ${userResponse.statusText}`);
187191
}
188-
const user: { id: string, login: string } = await userResponse.json();
192+
const user = await (userResponse.json() as Promise<{ id: string, login: string }>);
189193
return {
190194
id: user.id,
191195
accountName: user.login
@@ -774,7 +778,7 @@ interface IOpenVSXQueryResult {
774778
extensions: IOpenVSXExtensionsMetadata[];
775779
}
776780

777-
async function validateExtensions(extensionsToValidate: { id: string, version?: string }[], token: vscode.CancellationToken) {
781+
async function validateExtensions(extensionsToValidate: { id: string, version?: string }[], linkToValidate: string[], token: vscode.CancellationToken) {
778782
const allUserExtensions = vscode.extensions.all.filter(ext => !ext.packageJSON['isBuiltin'] && !ext.packageJSON['isUserBuiltin']);
779783

780784
const lookup = new Set<string>(extensionsToValidate.map(({ id }) => id));
@@ -795,7 +799,8 @@ async function validateExtensions(extensionsToValidate: { id: string, version?:
795799
return {
796800
extensions: [],
797801
missingMachined: [],
798-
uninstalled: []
802+
uninstalled: [],
803+
links: []
799804
};
800805
}
801806
}
@@ -806,21 +811,21 @@ async function validateExtensions(extensionsToValidate: { id: string, version?:
806811
`${process.env.VSX_REGISTRY_URL || 'https://open-vsx.org'}/api/-/query`,
807812
{
808813
method: 'POST',
809-
timeout: 5000,
810814
headers: {
811815
'Content-Type': 'application/json',
812816
'Accept': 'application/json'
813817
},
814818
body: JSON.stringify({
815819
extensionId: id
816-
})
820+
}),
821+
timeout: 2000
817822
}
818823
).then(resp => {
819824
if (!resp.ok) {
820825
console.error('Failed to query open-vsx while validating gitpod.yml');
821826
return undefined;
822827
}
823-
return resp.json();
828+
return resp.json() as Promise<IOpenVSXQueryResult>;
824829
}, e => {
825830
console.error('Fetch failed while querying open-vsx', e);
826831
return undefined;
@@ -837,17 +842,40 @@ async function validateExtensions(extensionsToValidate: { id: string, version?:
837842
return {
838843
extensions: [],
839844
missingMachined: [],
840-
uninstalled: []
845+
uninstalled: [],
846+
links: []
841847
};
842848
}
843849
}
844850

845-
// TODO: validate links
851+
const links = new Set<string>();
852+
for (const link of linkToValidate) {
853+
const downloadPath = path.join(os.tmpdir(), uuid.v4());
854+
try {
855+
await download(link, downloadPath, token, 10000);
856+
const manifest = await getManifest(downloadPath);
857+
if (manifest.engines?.vscode) {
858+
links.add(link);
859+
}
860+
} catch (error) {
861+
console.error('Failed to validate vsix url', error);
862+
}
863+
864+
if (token.isCancellationRequested) {
865+
return {
866+
extensions: [],
867+
missingMachined: [],
868+
uninstalled: [],
869+
links: []
870+
};
871+
}
872+
}
846873

847874
return {
848875
extensions: [...validatedExtensions],
849876
missingMachined: [...missingMachined],
850-
uninstalled: [...uninstalled]
877+
uninstalled: [...uninstalled],
878+
links: [...links]
851879
};
852880
}
853881

@@ -926,10 +954,7 @@ export function registerExtensionManagement(context: GitpodExtensionContext): vo
926954
}
927955
try {
928956
const toLink = new Map<string, vscode.Range>();
929-
const toFind = new Map<string, {
930-
version?: string,
931-
range: vscode.Range
932-
}>();
957+
const toFind = new Map<string, { version?: string, range: vscode.Range }>();
933958
let document: vscode.TextDocument | undefined;
934959
try {
935960
document = await vscode.workspace.openTextDocument(gitpodFileUri);
@@ -994,7 +1019,8 @@ export function registerExtensionManagement(context: GitpodExtensionContext): vo
9941019
}
9951020

9961021
const extensionsToValidate = [...toFind.entries()].map(([id, { version }]) => ({ id, version }));
997-
const result = await validateExtensions(extensionsToValidate, token);
1022+
const linksToValidate = [...toLink.keys()];
1023+
const result = await validateExtensions(extensionsToValidate, linksToValidate, token);
9981024

9991025
if (token.isCancellationRequested) {
10001026
return;
@@ -1016,14 +1042,14 @@ export function registerExtensionManagement(context: GitpodExtensionContext): vo
10161042
pushDiagnostic(diagnostic);
10171043
}
10181044

1019-
// for (const link of result.links) {
1020-
// toLink.delete(link);
1021-
// }
1022-
// for (const [link, range] of toLink) {
1023-
// const diagnostic = new vscode.Diagnostic(range, link + invalidVSIXLinkMessageSuffix, vscode.DiagnosticSeverity.Error);
1024-
// diagnostic.source = 'gitpod';
1025-
// pushDiagnostic(diagnostic);
1026-
// }
1045+
for (const link of result.links) {
1046+
toLink.delete(link);
1047+
}
1048+
for (const [link, range] of toLink) {
1049+
const diagnostic = new vscode.Diagnostic(range, link + invalidVSIXLinkMessageSuffix, vscode.DiagnosticSeverity.Error);
1050+
diagnostic.source = 'gitpod';
1051+
pushDiagnostic(diagnostic);
1052+
}
10271053

10281054
for (const id of result.missingMachined) {
10291055
const diagnostic = new vscode.Diagnostic(new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 1)), id + missingExtensionMessageSuffix, vscode.DiagnosticSeverity.Warning);
@@ -1158,13 +1184,9 @@ export function registerExtensionManagement(context: GitpodExtensionContext): vo
11581184
return codeActions;
11591185
}
11601186
}));
1187+
11611188
validateGitpodFile();
11621189
context.subscriptions.push(gitpodDiagnostics);
1163-
context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(e => {
1164-
if (e.document.uri.toString() === gitpodFileUri.toString()) {
1165-
validateGitpodFile();
1166-
}
1167-
}));
11681190
const gitpodFileWatcher = vscode.workspace.createFileSystemWatcher(gitpodFileUri.fsPath);
11691191
context.subscriptions.push(gitpodFileWatcher);
11701192
context.subscriptions.push(gitpodFileWatcher.onDidCreate(() => validateGitpodFile()));

extensions/gitpod-web/src/async.ts renamed to extensions/gitpod-web/src/util/async.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,49 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { CancellationToken, CancellationTokenSource } from 'vscode';
7+
8+
export interface CancelablePromise<T> extends Promise<T> {
9+
cancel(): void;
10+
}
11+
12+
export function createCancelablePromise<T>(callback: (token: CancellationToken) => Promise<T>): CancelablePromise<T> {
13+
const source = new CancellationTokenSource();
14+
15+
const thenable = callback(source.token);
16+
const promise = new Promise<T>((resolve, reject) => {
17+
const subscription = source.token.onCancellationRequested(() => {
18+
subscription.dispose();
19+
source.dispose();
20+
reject(new Error('Canceled Promise'));
21+
});
22+
Promise.resolve(thenable).then(value => {
23+
subscription.dispose();
24+
source.dispose();
25+
resolve(value);
26+
}, err => {
27+
subscription.dispose();
28+
source.dispose();
29+
reject(err);
30+
});
31+
});
32+
33+
return <CancelablePromise<T>>new class {
34+
cancel() {
35+
source.cancel();
36+
}
37+
then<TResult1 = T, TResult2 = never>(resolve?: ((value: T) => TResult1 | Promise<TResult1>) | undefined | null, reject?: ((reason: any) => TResult2 | Promise<TResult2>) | undefined | null): Promise<TResult1 | TResult2> {
38+
return promise.then(resolve, reject);
39+
}
40+
catch<TResult = never>(reject?: ((reason: any) => TResult | Promise<TResult>) | undefined | null): Promise<T | TResult> {
41+
return this.then(undefined, reject);
42+
}
43+
finally(onfinally?: (() => void) | undefined | null): Promise<T> {
44+
return promise.finally(onfinally);
45+
}
46+
};
47+
}
48+
649
export interface ITask<T> {
750
(): T;
851
}
@@ -77,6 +120,15 @@ export class Throttler<T> {
77120
}
78121
}
79122

123+
export class Sequencer {
124+
125+
private current: Promise<unknown> = Promise.resolve(null);
126+
127+
queue<T>(promiseTask: ITask<Promise<T>>): Promise<T> {
128+
return this.current = this.current.then(() => promiseTask(), () => promiseTask());
129+
}
130+
}
131+
80132
/**
81133
* A helper to delay execution of a task that is being requested often.
82134
*
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import * as fs from 'fs';
6+
import * as https from 'https';
7+
import * as http from 'http';
8+
import { basename } from 'path';
9+
import { URL } from 'url';
10+
import { createGunzip } from 'zlib';
11+
import * as stream from 'stream';
12+
import * as vscode from 'vscode';
13+
14+
export function download(url: string, dest: string, token: vscode.CancellationToken, timeout?: number): Promise<void> {
15+
const uri = new URL(url);
16+
if (!dest) {
17+
dest = basename(uri.pathname);
18+
}
19+
const pkg = url.toLowerCase().startsWith('https:') ? https : http;
20+
21+
return new Promise((resolve, reject) => {
22+
const request = pkg.get(uri.href).on('response', (res: http.IncomingMessage) => {
23+
if (res.statusCode === 200) {
24+
const file = fs.createWriteStream(dest, { flags: 'wx' });
25+
res.on('end', () => {
26+
file.end();
27+
// console.log(`${uri.pathname} downloaded to: ${path}`)
28+
resolve();
29+
}).on('error', (err: any) => {
30+
file.destroy();
31+
fs.unlink(dest, () => reject(err));
32+
});
33+
34+
let dataStream: stream.Readable = res;
35+
if (res.headers['content-encoding'] === 'gzip') {
36+
dataStream = res.pipe(createGunzip());
37+
}
38+
dataStream.pipe(file);
39+
} else if (res.statusCode === 302 || res.statusCode === 301) {
40+
// Recursively follow redirects, only a 200 will resolve.
41+
download(res.headers.location!, dest, token, timeout).then(() => resolve());
42+
} else {
43+
reject(new Error(`Download request failed, response status: ${res.statusCode} ${res.statusMessage}`));
44+
}
45+
});
46+
47+
if (timeout) {
48+
request.setTimeout(timeout, () => {
49+
request.abort();
50+
reject(new Error(`Request timeout after ${timeout / 1000.0}s`));
51+
});
52+
}
53+
54+
token.onCancellationRequested(() => {
55+
request.abort();
56+
reject(new Error(`Request cancelled`));
57+
});
58+
});
59+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { buffer } from './zip';
7+
8+
export type ExtensionKind = 'ui' | 'workspace' | 'web';
9+
10+
export interface IExtensionManifest {
11+
readonly name: string;
12+
readonly displayName?: string;
13+
readonly publisher: string;
14+
readonly version: string;
15+
readonly engines: { readonly vscode: string };
16+
readonly description?: string;
17+
readonly main?: string;
18+
readonly browser?: string;
19+
readonly icon?: string;
20+
readonly categories?: string[];
21+
readonly keywords?: string[];
22+
readonly activationEvents?: string[];
23+
readonly extensionDependencies?: string[];
24+
readonly extensionPack?: string[];
25+
readonly extensionKind?: ExtensionKind | ExtensionKind[];
26+
readonly contributes?: any;
27+
readonly repository?: { url: string; };
28+
readonly bugs?: { url: string; };
29+
readonly enableProposedApi?: boolean;
30+
readonly api?: string;
31+
readonly scripts?: { [key: string]: string; };
32+
readonly capabilities?: any;
33+
}
34+
35+
export function getManifest(vsix: string): Promise<IExtensionManifest> {
36+
return buffer(vsix, 'extension/package.json')
37+
.then(buffer => {
38+
try {
39+
return JSON.parse(buffer.toString('utf8'));
40+
} catch (err) {
41+
throw new Error('VSIX invalid: package.json is not a JSON file.');
42+
}
43+
});
44+
}

0 commit comments

Comments
 (0)