ovsx 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/lib/check-license.d.ts.map +1 -1
- package/lib/check-license.js +3 -2
- package/lib/check-license.js.map +1 -1
- package/lib/get.js +5 -5
- package/lib/get.js.map +1 -1
- package/lib/http.d.ts +32 -0
- package/lib/http.d.ts.map +1 -0
- package/lib/http.js +170 -0
- package/lib/http.js.map +1 -0
- package/lib/main.js +44 -41
- package/lib/main.js.map +1 -1
- package/lib/oidc.d.ts.map +1 -1
- package/lib/oidc.js +5 -47
- package/lib/oidc.js.map +1 -1
- package/lib/publish.d.ts.map +1 -1
- package/lib/publish.js +100 -32
- package/lib/publish.js.map +1 -1
- package/lib/registry.d.ts +49 -11
- package/lib/registry.d.ts.map +1 -1
- package/lib/registry.js +115 -124
- package/lib/registry.js.map +1 -1
- package/lib/util.d.ts +16 -12
- package/lib/util.d.ts.map +1 -1
- package/lib/util.js +44 -67
- package/lib/util.js.map +1 -1
- package/lib/verify.js +2 -2
- package/lib/verify.js.map +1 -1
- package/lib/zip.d.ts.map +1 -1
- package/lib/zip.js +2 -10
- package/lib/zip.js.map +1 -1
- package/package.json +14 -11
- package/src/check-license.ts +4 -3
- package/src/get.ts +6 -6
- package/src/http.ts +176 -0
- package/src/main.ts +48 -43
- package/src/oidc.ts +5 -47
- package/src/publish.ts +136 -34
- package/src/registry.ts +132 -124
- package/src/util.ts +40 -69
- package/src/verify.ts +3 -3
- package/src/zip.ts +2 -12
- package/lib/version.d.ts +0 -2
- package/lib/version.d.ts.map +0 -1
- package/lib/version.js +0 -5
- package/lib/version.js.map +0 -1
- package/src/version.ts +0 -1
package/src/registry.ts
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
import * as http from 'http';
|
|
12
12
|
import * as fs from 'fs';
|
|
13
|
-
import
|
|
14
|
-
import
|
|
13
|
+
import * as semver from 'semver';
|
|
14
|
+
import { pipeline } from 'stream';
|
|
15
|
+
import { jsonResponse, send } from './http';
|
|
15
16
|
import { RegistryOptions } from './registry-options';
|
|
16
|
-
import { DEFAULT_TIMEOUT, redactUrl, rejectError, statusError
|
|
17
|
+
import { DEFAULT_TIMEOUT, redactUrl, rejectError, statusError } from './util';
|
|
17
18
|
|
|
18
19
|
export const DEFAULT_URL = 'https://open-vsx.org';
|
|
19
20
|
export const DEFAULT_NAMESPACE_SIZE = 1024;
|
|
@@ -22,6 +23,18 @@ export { DEFAULT_TIMEOUT };
|
|
|
22
23
|
export const DEFAULT_TOKEN_REQUEST_SIZE = 8 * 1024;
|
|
23
24
|
export const DEFAULT_DELETE_SIZE = 64 * 1024;
|
|
24
25
|
|
|
26
|
+
// Fallback only, for when Authorization is already claimed by Basic auth to a fronting proxy (see
|
|
27
|
+
// tokenHeaders/getRequestOptions).
|
|
28
|
+
// Must stay in the credential headers http.ts drops on a cross-origin redirect.
|
|
29
|
+
const TOKEN_HEADER = 'X-OpenVSX-Token';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Oldest registry version that resolves the personal access token from a header (see #1344).
|
|
33
|
+
* Registries older than this only look at the `token` query parameter, so `tokenQuery` keeps
|
|
34
|
+
* sending it to anything below this version.
|
|
35
|
+
*/
|
|
36
|
+
export const MIN_TOKEN_HEADER_REGISTRY_VERSION = '1.3.0';
|
|
37
|
+
|
|
25
38
|
export class Registry {
|
|
26
39
|
|
|
27
40
|
readonly url: string;
|
|
@@ -30,6 +43,10 @@ export class Registry {
|
|
|
30
43
|
readonly timeout: number;
|
|
31
44
|
readonly username?: string;
|
|
32
45
|
readonly password?: string;
|
|
46
|
+
/** Whether the caller pinned a publish size, which then wins over any limit the registry reports. */
|
|
47
|
+
private readonly publishSizePinned: boolean;
|
|
48
|
+
private registryVersion?: Promise<RegistryVersion>;
|
|
49
|
+
private tokenHeaderSupport?: Promise<boolean>;
|
|
33
50
|
|
|
34
51
|
constructor(options: RegistryOptions = {}) {
|
|
35
52
|
if (options.registryUrl?.endsWith('/'))
|
|
@@ -40,6 +57,7 @@ export class Registry {
|
|
|
40
57
|
this.url = DEFAULT_URL;
|
|
41
58
|
|
|
42
59
|
this.maxNamespaceSize = options.maxNamespaceSize ?? DEFAULT_NAMESPACE_SIZE;
|
|
60
|
+
this.publishSizePinned = options.maxPublishSize !== undefined;
|
|
43
61
|
this.maxPublishSize = options.maxPublishSize ?? DEFAULT_PUBLISH_SIZE;
|
|
44
62
|
this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
45
63
|
this.username = options.username;
|
|
@@ -51,45 +69,81 @@ export class Registry {
|
|
|
51
69
|
return url.hostname === 'open-vsx.org' || url.hostname.endsWith('.open-vsx.org');
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
createNamespace(name: string, pat: string): Promise<Response> {
|
|
72
|
+
async createNamespace(name: string, pat: string): Promise<Response> {
|
|
55
73
|
try {
|
|
56
|
-
const url = this.getUrl(['api', '-', 'namespace', 'create'],
|
|
74
|
+
const url = this.getUrl(['api', '-', 'namespace', 'create'], await this.tokenQuery(pat));
|
|
57
75
|
const namespace = { name };
|
|
58
|
-
return this.post(JSON.stringify(namespace), url, {
|
|
59
|
-
'Content-Type': 'application/json'
|
|
76
|
+
return await this.post(JSON.stringify(namespace), url, {
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
...this.tokenHeaders(pat)
|
|
60
79
|
}, this.maxNamespaceSize);
|
|
61
80
|
} catch (err) {
|
|
62
81
|
return rejectError(err);
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
84
|
|
|
66
|
-
verifyPat(namespace: string, pat: string): Promise<Response> {
|
|
85
|
+
async verifyPat(namespace: string, pat: string): Promise<Response> {
|
|
86
|
+
try {
|
|
87
|
+
const url = this.getUrl(['api', namespace, 'verify-pat'], await this.tokenQuery(pat));
|
|
88
|
+
return await this.getJson(url, this.tokenHeaders(pat));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return rejectError(err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The size limit that applies to this namespace/extension, which a size override can raise above
|
|
96
|
+
* the registry default. Neither needs to exist yet - a first publish asks about both before
|
|
97
|
+
* creating either.
|
|
98
|
+
*/
|
|
99
|
+
async getSizeLimit(namespace: string, extension: string, pat: string): Promise<SizeLimit> {
|
|
67
100
|
try {
|
|
68
|
-
|
|
101
|
+
const query = { namespace, extension, ...(await this.tokenQuery(pat)) };
|
|
102
|
+
const url = this.getUrl(['api', '-', 'size-limit'], query);
|
|
103
|
+
return await this.getJson(url, this.tokenHeaders(pat));
|
|
69
104
|
} catch (err) {
|
|
70
105
|
return rejectError(err);
|
|
71
106
|
}
|
|
72
107
|
}
|
|
73
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Cached per `Registry` instance - callers like `tokenQuery` and `unpublish`'s own version check
|
|
111
|
+
* would otherwise each fetch it separately, doubling the round trip for a single command.
|
|
112
|
+
*/
|
|
74
113
|
getRegistryVersion(): Promise<RegistryVersion> {
|
|
75
114
|
try {
|
|
76
|
-
return this.getJson(this.getUrl(['api', 'version']));
|
|
115
|
+
return this.registryVersion ??= this.getJson(this.getUrl(['api', 'version']));
|
|
77
116
|
} catch (err) {
|
|
78
117
|
return rejectError(err);
|
|
79
118
|
}
|
|
80
119
|
}
|
|
81
120
|
|
|
82
|
-
|
|
121
|
+
/**
|
|
122
|
+
* `sizeLimit` is the limit the registry reports for this package, when it could report one. The
|
|
123
|
+
* transport body cap is raised to it, because a namespace granted more than the default would
|
|
124
|
+
* otherwise have its upload refused here - by the body size check, before anything reached the
|
|
125
|
+
* registry that allowed it.
|
|
126
|
+
*/
|
|
127
|
+
async publish(file: string, pat: string, sizeLimit?: number): Promise<Extension> {
|
|
83
128
|
try {
|
|
84
|
-
const url = this.getUrl(['api', '-', 'publish'],
|
|
85
|
-
return this.postFile(file, url, {
|
|
86
|
-
'Content-Type': 'application/octet-stream'
|
|
87
|
-
|
|
129
|
+
const url = this.getUrl(['api', '-', 'publish'], await this.tokenQuery(pat));
|
|
130
|
+
return await this.postFile(file, url, {
|
|
131
|
+
'Content-Type': 'application/octet-stream',
|
|
132
|
+
...this.tokenHeaders(pat)
|
|
133
|
+
}, this.publishBodyLimit(sizeLimit));
|
|
88
134
|
} catch (err) {
|
|
89
135
|
return rejectError(err);
|
|
90
136
|
}
|
|
91
137
|
}
|
|
92
138
|
|
|
139
|
+
/** The body cap for a publish. A pinned `maxPublishSize` wins; otherwise it never sits below the registry's own limit. */
|
|
140
|
+
publishBodyLimit(sizeLimit?: number): number {
|
|
141
|
+
if (this.publishSizePinned || sizeLimit === undefined) {
|
|
142
|
+
return this.maxPublishSize;
|
|
143
|
+
}
|
|
144
|
+
return Math.max(this.maxPublishSize, sizeLimit);
|
|
145
|
+
}
|
|
146
|
+
|
|
93
147
|
requestTrustedPublishingToken(namespace: string, extension: string, idToken: string): Promise<AccessToken> {
|
|
94
148
|
try {
|
|
95
149
|
const url = this.getUrl(['api', '-', 'trusted-publishing', 'token']);
|
|
@@ -106,7 +160,7 @@ export class Registry {
|
|
|
106
160
|
* Deletes extension versions. Omitting `targetVersions` deletes the extension as a whole,
|
|
107
161
|
* i.e. all versions the personal access token's user is allowed to delete.
|
|
108
162
|
*/
|
|
109
|
-
deleteExtension(
|
|
163
|
+
async deleteExtension(
|
|
110
164
|
namespace: string,
|
|
111
165
|
extension: string,
|
|
112
166
|
targetVersions: TargetPlatformVersion[] | undefined,
|
|
@@ -114,13 +168,15 @@ export class Registry {
|
|
|
114
168
|
): Promise<Response> {
|
|
115
169
|
try {
|
|
116
170
|
if (!targetVersions) {
|
|
117
|
-
const
|
|
118
|
-
|
|
171
|
+
const query = { ...await this.tokenQuery(pat), allVersions: 'true' };
|
|
172
|
+
const url = this.getUrl(['api', namespace, extension, 'delete'], query);
|
|
173
|
+
return await this.post('', url, this.tokenHeaders(pat), DEFAULT_DELETE_SIZE);
|
|
119
174
|
}
|
|
120
175
|
|
|
121
|
-
const url = this.getUrl(['api', namespace, extension, 'delete'],
|
|
122
|
-
return this.post(JSON.stringify(targetVersions), url, {
|
|
123
|
-
'Content-Type': 'application/json'
|
|
176
|
+
const url = this.getUrl(['api', namespace, extension, 'delete'], await this.tokenQuery(pat));
|
|
177
|
+
return await this.post(JSON.stringify(targetVersions), url, {
|
|
178
|
+
'Content-Type': 'application/json',
|
|
179
|
+
...this.tokenHeaders(pat)
|
|
124
180
|
}, DEFAULT_DELETE_SIZE);
|
|
125
181
|
} catch (err) {
|
|
126
182
|
return rejectError(err);
|
|
@@ -232,9 +288,7 @@ export class Registry {
|
|
|
232
288
|
fs.rm(partial, { force: true }, () => reject(err));
|
|
233
289
|
};
|
|
234
290
|
|
|
235
|
-
|
|
236
|
-
const request = this.getProtocol(url)
|
|
237
|
-
.request(url, requestOptions, response => {
|
|
291
|
+
send(url, this.getRequestOptions(), undefined, response => {
|
|
238
292
|
if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
|
|
239
293
|
response.resume();
|
|
240
294
|
reject(statusError(response));
|
|
@@ -261,55 +315,64 @@ export class Registry {
|
|
|
261
315
|
fs.rename(partial, file, renameErr => renameErr ? fail(renameErr) : resolve());
|
|
262
316
|
}
|
|
263
317
|
});
|
|
264
|
-
});
|
|
265
|
-
request.on('error', (err: Error) => fail(err));
|
|
266
|
-
this.failOnTimeout(request, url, fail);
|
|
267
|
-
request.end();
|
|
318
|
+
}, fail);
|
|
268
319
|
});
|
|
269
320
|
}
|
|
270
321
|
|
|
271
|
-
getJson<T extends Response>(url: URL): Promise<T> {
|
|
322
|
+
getJson<T extends Response>(url: URL, headers?: http.OutgoingHttpHeaders): Promise<T> {
|
|
272
323
|
return new Promise((resolve, reject) => {
|
|
273
|
-
|
|
274
|
-
const request = this.getProtocol(url)
|
|
275
|
-
.request(url, requestOptions, this.getJsonResponse<T>(resolve, reject));
|
|
276
|
-
request.on('error', reject);
|
|
277
|
-
this.failOnTimeout(request, url, reject);
|
|
278
|
-
request.end();
|
|
324
|
+
send(url, this.getRequestOptions('GET', headers), undefined, jsonResponse<T>(resolve, reject), reject);
|
|
279
325
|
});
|
|
280
326
|
}
|
|
281
327
|
|
|
282
328
|
post<T extends Response>(content: string | Buffer | Uint8Array, url: URL, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): Promise<T> {
|
|
283
329
|
return new Promise((resolve, reject) => {
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
request.on('error', reject);
|
|
288
|
-
this.failOnTimeout(request, url, reject);
|
|
289
|
-
request.write(content);
|
|
290
|
-
request.end();
|
|
330
|
+
const size = typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
|
|
331
|
+
checkBodySize(size, maxBodyLength);
|
|
332
|
+
send(url, this.getRequestOptions('POST', headers), () => content, jsonResponse<T>(resolve, reject), reject);
|
|
291
333
|
});
|
|
292
334
|
}
|
|
293
335
|
|
|
294
|
-
postFile<T extends Response>(file: string, url: URL, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): Promise<T> {
|
|
336
|
+
async postFile<T extends Response>(file: string, url: URL, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): Promise<T> {
|
|
337
|
+
checkBodySize((await fs.promises.stat(file)).size, maxBodyLength);
|
|
295
338
|
return new Promise((resolve, reject) => {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
.request(url, requestOptions, this.getJsonResponse<T>(resolve, reject));
|
|
300
|
-
stream.on('error', (err: Error) => {
|
|
301
|
-
request.destroy();
|
|
302
|
-
reject(err);
|
|
303
|
-
});
|
|
304
|
-
request.on('error', (err: Error) => {
|
|
305
|
-
stream.close();
|
|
306
|
-
reject(err);
|
|
307
|
-
});
|
|
308
|
-
this.failOnTimeout(request, url, reject);
|
|
309
|
-
stream.on('open', () => stream.pipe(request));
|
|
339
|
+
// a stream can be read once, so each redirect hop opens the file again
|
|
340
|
+
send(url, this.getRequestOptions('POST', headers), () => fs.createReadStream(file),
|
|
341
|
+
jsonResponse<T>(resolve, reject), reject);
|
|
310
342
|
});
|
|
311
343
|
}
|
|
312
344
|
|
|
345
|
+
/**
|
|
346
|
+
* The header a personal access token travels in. `Authorization: Bearer` is standard and what
|
|
347
|
+
* log/proxy redaction and secret scanners already expect, so it's preferred - except when
|
|
348
|
+
* `username`/`password` are set, where `Authorization` is already claimed by Basic auth to a
|
|
349
|
+
* fronting reverse proxy (see `getRequestOptions`) and the token falls back to `TOKEN_HEADER`.
|
|
350
|
+
*/
|
|
351
|
+
private tokenHeaders(pat: string): http.OutgoingHttpHeaders {
|
|
352
|
+
return (this.username && this.password)
|
|
353
|
+
? { [TOKEN_HEADER]: pat }
|
|
354
|
+
: { Authorization: `Bearer ${pat}` };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The `token` query parameter to add alongside the header, for registries that predate header
|
|
359
|
+
* support (see #1344) - empty once the registry is known to be new enough to not need it.
|
|
360
|
+
*
|
|
361
|
+
* Best-effort and cached per `Registry` instance: a registry that doesn't expose `/api/version`,
|
|
362
|
+
* or reports a version that doesn't parse as semver, is assumed too old to know about the header,
|
|
363
|
+
* same as before this method existed - the query parameter is kept rather than risking a request
|
|
364
|
+
* that only carries a header such a registry never looks at.
|
|
365
|
+
*/
|
|
366
|
+
private tokenQuery(pat: string): Promise<Record<string, string>> {
|
|
367
|
+
return (this.tokenHeaderSupport ??= this.getRegistryVersion()
|
|
368
|
+
.then(({ version }) => {
|
|
369
|
+
const parsed = semver.coerce(version);
|
|
370
|
+
return !!parsed && semver.gte(parsed, MIN_TOKEN_HEADER_REGISTRY_VERSION);
|
|
371
|
+
})
|
|
372
|
+
.catch(() => false)
|
|
373
|
+
).then(supported => supported ? {} : { token: pat } as Record<string, string>);
|
|
374
|
+
}
|
|
375
|
+
|
|
313
376
|
private getUrl(segments: string[], query?: Record<string, string>): URL {
|
|
314
377
|
const url = new URL(this.url);
|
|
315
378
|
const basePath = url.pathname.replace(/\/+$/, '');
|
|
@@ -321,33 +384,7 @@ export class Registry {
|
|
|
321
384
|
return url;
|
|
322
385
|
}
|
|
323
386
|
|
|
324
|
-
private
|
|
325
|
-
return url.protocol === 'https:' ? followRedirects.https : followRedirects.http;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Node's `timeout` option only raises an event - the request stays open, which is why a server
|
|
330
|
-
* that accepts a connection and then says nothing used to hold a command open indefinitely.
|
|
331
|
-
* Destroying the request with an error routes through the error handling each caller already
|
|
332
|
-
* has, so a stalled request rejects the way any other failure does. With a timeout of zero the
|
|
333
|
-
* event never fires and this does nothing.
|
|
334
|
-
*/
|
|
335
|
-
// Typed on Writable because both http.ClientRequest and follow-redirects' wrapper are ones, and
|
|
336
|
-
// all this needs is the timeout event and destroy.
|
|
337
|
-
private failOnTimeout(request: Writable, url: URL, fail: (err: Error) => void): void {
|
|
338
|
-
request.on('timeout', () => {
|
|
339
|
-
// Reported before the request is torn down, rather than by destroying it with the error.
|
|
340
|
-
// Destroying raises errors of its own - ECONNRESET on the response, a premature close
|
|
341
|
-
// from pipeline - and node guarantees no order between those and the request's own. In
|
|
342
|
-
// practice the request's arrives first, so this is belt and braces rather than the thing
|
|
343
|
-
// that stops the wrong error being reported; that is the caller claiming the failure
|
|
344
|
-
// before it does any asynchronous cleanup.
|
|
345
|
-
fail(new Error(`No response from ${redactUrl(url)} for ${this.timeout} ms.`));
|
|
346
|
-
request.destroy();
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
private getRequestOptions(method?: string, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): http.RequestOptions {
|
|
387
|
+
private getRequestOptions(method?: string, headers?: http.OutgoingHttpHeaders): http.RequestOptions {
|
|
351
388
|
if (this.username && this.password) {
|
|
352
389
|
headers ??= {};
|
|
353
390
|
const credentials = Buffer.from(this.username + ':' + this.password).toString('base64');
|
|
@@ -356,52 +393,19 @@ export class Registry {
|
|
|
356
393
|
return {
|
|
357
394
|
method,
|
|
358
395
|
headers,
|
|
359
|
-
maxBodyLength,
|
|
360
396
|
timeout: this.timeout
|
|
361
|
-
} as http.RequestOptions;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
private getJsonResponse<T extends Response>(resolve: (value: T) => void, reject: (reason: any) => void): (res: http.IncomingMessage) => void {
|
|
365
|
-
return response => {
|
|
366
|
-
response.setEncoding('utf-8');
|
|
367
|
-
let json = '';
|
|
368
|
-
// A connection lost after the headers have arrived ends the response without 'end' ever
|
|
369
|
-
// firing, so without this the promise is never settled and the command waits on a body
|
|
370
|
-
// that is not coming. The configured timeout would eventually rescue it, but rejecting
|
|
371
|
-
// here reports what actually happened instead of thirty seconds of silence.
|
|
372
|
-
response.on('error', reject);
|
|
373
|
-
response.on('data', chunk => json += chunk);
|
|
374
|
-
response.on('end', () => {
|
|
375
|
-
if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
|
|
376
|
-
if (json.startsWith('{')) {
|
|
377
|
-
try {
|
|
378
|
-
const parsed = JSON.parse(json) as ErrorResponse;
|
|
379
|
-
const message = parsed.message || parsed.error;
|
|
380
|
-
if (message) {
|
|
381
|
-
// keep the status: the message alone cannot say whether retrying is worth it
|
|
382
|
-
reject(withStatus(new Error(message), response.statusCode));
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
} catch (err) {
|
|
386
|
-
// Ignore the error and reject with response status
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
reject(statusError(response));
|
|
390
|
-
} else if (json.startsWith('<!DOCTYPE html>')) {
|
|
391
|
-
reject(json);
|
|
392
|
-
} else {
|
|
393
|
-
try {
|
|
394
|
-
resolve(JSON.parse(json));
|
|
395
|
-
} catch (err) {
|
|
396
|
-
reject(err);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
397
|
};
|
|
401
398
|
}
|
|
402
399
|
|
|
403
400
|
}
|
|
404
401
|
|
|
402
|
+
/** Refuses a request body larger than `maxBodyLength` before anything is sent. */
|
|
403
|
+
function checkBodySize(size: number, maxBodyLength?: number): void {
|
|
404
|
+
if (maxBodyLength !== undefined && size > maxBodyLength) {
|
|
405
|
+
throw new Error(`The request body of ${size} bytes is larger than the limit of ${maxBodyLength} bytes.`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
405
409
|
export interface Response {
|
|
406
410
|
success?: string;
|
|
407
411
|
warning?: string;
|
|
@@ -466,6 +470,10 @@ export interface RegistryVersion extends Response {
|
|
|
466
470
|
trustedPublishingAudience?: string;
|
|
467
471
|
}
|
|
468
472
|
|
|
473
|
+
export interface SizeLimit extends Response {
|
|
474
|
+
maxSize: number;
|
|
475
|
+
}
|
|
476
|
+
|
|
469
477
|
export interface AccessToken extends Response {
|
|
470
478
|
id: number;
|
|
471
479
|
value?: string;
|
package/src/util.ts
CHANGED
|
@@ -8,15 +8,14 @@
|
|
|
8
8
|
* SPDX-License-Identifier: EPL-2.0
|
|
9
9
|
********************************************************************************/
|
|
10
10
|
|
|
11
|
+
import * as crypto from 'crypto';
|
|
11
12
|
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
12
14
|
import * as path from 'path';
|
|
13
|
-
import * as tmp from 'tmp';
|
|
14
15
|
import * as http from 'http';
|
|
15
16
|
import { RegistryOptions } from './registry-options';
|
|
16
17
|
import { TrustedPublishingOptions } from './trusted-publishing-options';
|
|
17
18
|
|
|
18
|
-
export { promisify } from 'util';
|
|
19
|
-
|
|
20
19
|
export function addEnvOptions(options: RegistryOptions): void {
|
|
21
20
|
options.registryUrl ??= process.env.OVSX_REGISTRY_URL;
|
|
22
21
|
options.pat ??= process.env.OVSX_PAT;
|
|
@@ -76,36 +75,39 @@ export function matchExtensionId(id: string): RegExpExecArray | null {
|
|
|
76
75
|
return /^([\w-]+)(?:\.|\/)([\w-]+)$/.exec(id);
|
|
77
76
|
}
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Levenshtein distance between `a` and `b`: the minimum number of single-character insertions,
|
|
80
|
+
* deletions or substitutions needed to turn one into the other. Used to suggest the closest known
|
|
81
|
+
* command for a typo.
|
|
82
|
+
*/
|
|
83
|
+
export function levenshtein(a: string, b: string): number {
|
|
84
|
+
if (a === b) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
// The distance is symmetric, so keep the shorter string in `b` to bound the row size.
|
|
88
|
+
if (b.length > a.length) {
|
|
89
|
+
[a, b] = [b, a];
|
|
90
|
+
}
|
|
91
|
+
// A single row of the edit distance matrix, seeded with the distance between the empty prefix
|
|
92
|
+
// of `a` and every prefix of `b`.
|
|
93
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
94
|
+
for (let i = 1; i <= a.length; i++) {
|
|
95
|
+
let diagonal = row[0];
|
|
96
|
+
row[0] = i;
|
|
97
|
+
for (let j = 1; j <= b.length; j++) {
|
|
98
|
+
const above = row[j];
|
|
99
|
+
row[j] = Math.min(row[j - 1] + 1, above + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
100
|
+
diagonal = above;
|
|
96
101
|
}
|
|
97
|
-
}
|
|
102
|
+
}
|
|
103
|
+
return row[b.length];
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
else
|
|
106
|
-
resolve(name);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
106
|
+
/**
|
|
107
|
+
* Returns a random, not-yet-created file path ending in `postfix`, directly in the OS temp directory.
|
|
108
|
+
*/
|
|
109
|
+
export function getTempFilePath(postfix = ''): string {
|
|
110
|
+
return path.join(os.tmpdir(), `ovsx-${crypto.randomUUID()}${postfix}`);
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
export function rejectError(err: any) {
|
|
@@ -167,10 +169,12 @@ export function withStatus(error: Error, status?: number): StatusError {
|
|
|
167
169
|
/**
|
|
168
170
|
* A URL in the form it can safely be printed: no user info and no query string.
|
|
169
171
|
*
|
|
170
|
-
*
|
|
171
|
-
* `
|
|
172
|
-
*
|
|
173
|
-
*
|
|
172
|
+
* The personal access token travels in a header now, but `createNamespace`, `verifyPat`, `publish`
|
|
173
|
+
* and `delete` still carry it in the query string too when talking to a registry too old to resolve
|
|
174
|
+
* it from the header (see `Registry.tokenQuery`, #1344). An error message goes straight to stderr
|
|
175
|
+
* and from there into CI logs, and the query carries nothing an error needs - so it stays dropped
|
|
176
|
+
* whole rather than filtered key by key, a filter having to be kept in step with every parameter
|
|
177
|
+
* anyone ever adds.
|
|
174
178
|
*/
|
|
175
179
|
export function redactUrl(url: URL | string): string {
|
|
176
180
|
try {
|
|
@@ -189,24 +193,8 @@ export function statusError(response: http.IncomingMessage): StatusError {
|
|
|
189
193
|
return withStatus(new Error(message), response.statusCode);
|
|
190
194
|
}
|
|
191
195
|
|
|
192
|
-
export function readFile(name: string, packagePath?: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
|
|
193
|
-
return new Promise((resolve, reject) => {
|
|
194
|
-
fs.readFile(
|
|
195
|
-
path.join(packagePath ?? process.cwd(), name),
|
|
196
|
-
{ encoding },
|
|
197
|
-
(err: NodeJS.ErrnoException | null, content: string) => {
|
|
198
|
-
if (err) {
|
|
199
|
-
reject(err);
|
|
200
|
-
} else {
|
|
201
|
-
resolve(content);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
);
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
196
|
export async function readManifest(packagePath?: string): Promise<Manifest> {
|
|
209
|
-
const content = await readFile('package.json',
|
|
197
|
+
const content = await fs.promises.readFile(path.join(packagePath ?? process.cwd(), 'package.json'), 'utf-8');
|
|
210
198
|
return JSON.parse(content);
|
|
211
199
|
}
|
|
212
200
|
|
|
@@ -222,26 +210,9 @@ export function validateManifest(manifest: Manifest): void {
|
|
|
222
210
|
}
|
|
223
211
|
}
|
|
224
212
|
|
|
225
|
-
export function writeFile(name: string, content: string, packagePath?: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
|
|
226
|
-
return new Promise((resolve, reject) => {
|
|
227
|
-
fs.writeFile(
|
|
228
|
-
path.join(packagePath ?? process.cwd(), name),
|
|
229
|
-
content,
|
|
230
|
-
{ encoding },
|
|
231
|
-
(err: NodeJS.ErrnoException | null) => {
|
|
232
|
-
if (err) {
|
|
233
|
-
reject(err);
|
|
234
|
-
} else {
|
|
235
|
-
resolve();
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
);
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
|
|
242
213
|
export function writeManifest(manifest: Manifest, packagePath?: string): Promise<void> {
|
|
243
214
|
const content = JSON.stringify(manifest, null, 4);
|
|
244
|
-
return writeFile('package.json', content,
|
|
215
|
+
return fs.promises.writeFile(path.join(packagePath ?? process.cwd(), 'package.json'), content, 'utf-8');
|
|
245
216
|
}
|
|
246
217
|
|
|
247
218
|
export interface Manifest {
|
package/src/verify.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import * as crypto from 'crypto';
|
|
15
15
|
import * as fs from 'fs';
|
|
16
16
|
import { Registry, Extension } from './registry';
|
|
17
|
-
import { addEnvOptions,
|
|
17
|
+
import { addEnvOptions, getTempFilePath } from './util';
|
|
18
18
|
import { readVSIXPackage, readZip } from './zip';
|
|
19
19
|
import { VerifyOptions } from './verify-options';
|
|
20
20
|
|
|
@@ -82,7 +82,7 @@ async function resolveVersion(registry: Registry, extension: Extension, version:
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
async function downloadSignature(registry: Registry, url: string): Promise<Buffer> {
|
|
85
|
-
const sigzipPath =
|
|
85
|
+
const sigzipPath = getTempFilePath('.sigzip');
|
|
86
86
|
try {
|
|
87
87
|
await registry.download(sigzipPath, new URL(url));
|
|
88
88
|
const entries = await readZip(sigzipPath, name => name === SIGNATURE_ENTRY);
|
|
@@ -97,7 +97,7 @@ async function downloadSignature(registry: Registry, url: string): Promise<Buffe
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
async function downloadPublicKey(registry: Registry, url: string): Promise<string> {
|
|
100
|
-
const publicKeyPath =
|
|
100
|
+
const publicKeyPath = getTempFilePath('.pem');
|
|
101
101
|
try {
|
|
102
102
|
await registry.download(publicKeyPath, new URL(url));
|
|
103
103
|
return await fs.promises.readFile(publicKeyPath, 'utf-8');
|
package/src/zip.ts
CHANGED
|
@@ -9,19 +9,10 @@
|
|
|
9
9
|
********************************************************************************/
|
|
10
10
|
import * as crypto from 'crypto';
|
|
11
11
|
import * as yauzl from 'yauzl-promise';
|
|
12
|
-
import {
|
|
12
|
+
import { buffer } from 'stream/consumers';
|
|
13
13
|
import { pipeline } from 'stream/promises';
|
|
14
14
|
import { Manifest } from './util';
|
|
15
15
|
|
|
16
|
-
async function bufferStream(stream: Readable): Promise<Buffer> {
|
|
17
|
-
return await new Promise((resolve, reject) => {
|
|
18
|
-
const buffers: Buffer[] = [];
|
|
19
|
-
stream.on('data', buffer => buffers.push(buffer));
|
|
20
|
-
stream.once('error', reject);
|
|
21
|
-
stream.once('end', () => resolve(Buffer.concat(buffers)));
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
|
|
25
16
|
export async function readZip(packagePath: string, filter: (name: string) => boolean): Promise<Map<string, Buffer>> {
|
|
26
17
|
const result = new Map<string, Buffer>();
|
|
27
18
|
const zipfile = await yauzl.open(packagePath);
|
|
@@ -30,8 +21,7 @@ export async function readZip(packagePath: string, filter: (name: string) => boo
|
|
|
30
21
|
const name = entry.filename.toLowerCase();
|
|
31
22
|
if (filter(name)) {
|
|
32
23
|
const stream = await zipfile.openReadStream(entry);
|
|
33
|
-
|
|
34
|
-
result.set(name, buffer);
|
|
24
|
+
result.set(name, await buffer(stream));
|
|
35
25
|
}
|
|
36
26
|
}
|
|
37
27
|
} finally {
|
package/lib/version.d.ts
DELETED
package/lib/version.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,UAAU,CAAC"}
|
package/lib/version.js
DELETED
package/lib/version.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":";;;AAAa,QAAA,WAAW,GAAG,OAAO,CAAC"}
|
package/src/version.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const LIB_VERSION = "1.2.0";
|