ovsx 1.1.0 → 1.2.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.
Files changed (121) hide show
  1. package/README.md +71 -1
  2. package/lib/get.js +1 -1
  3. package/lib/get.js.map +1 -1
  4. package/lib/index.d.ts +4 -0
  5. package/lib/index.d.ts.map +1 -1
  6. package/lib/index.js +4 -0
  7. package/lib/index.js.map +1 -1
  8. package/lib/list-options.d.ts +24 -0
  9. package/lib/list-options.d.ts.map +1 -0
  10. package/lib/list-options.js +15 -0
  11. package/lib/list-options.js.map +1 -0
  12. package/lib/list.d.ts +18 -0
  13. package/lib/list.d.ts.map +1 -0
  14. package/lib/list.js +46 -0
  15. package/lib/list.js.map +1 -0
  16. package/lib/main.js +78 -2
  17. package/lib/main.js.map +1 -1
  18. package/lib/oidc.d.ts +22 -0
  19. package/lib/oidc.d.ts.map +1 -0
  20. package/lib/oidc.js +101 -0
  21. package/lib/oidc.js.map +1 -0
  22. package/lib/publish-options.d.ts +8 -1
  23. package/lib/publish-options.d.ts.map +1 -1
  24. package/lib/publish.d.ts.map +1 -1
  25. package/lib/publish.js +73 -4
  26. package/lib/publish.js.map +1 -1
  27. package/lib/registry-options.d.ts +8 -0
  28. package/lib/registry-options.d.ts.map +1 -1
  29. package/lib/registry.d.ts +119 -1
  30. package/lib/registry.d.ts.map +1 -1
  31. package/lib/registry.js +187 -19
  32. package/lib/registry.js.map +1 -1
  33. package/lib/search-options.d.ts +48 -0
  34. package/lib/search-options.d.ts.map +1 -0
  35. package/lib/search-options.js +15 -0
  36. package/lib/search-options.js.map +1 -0
  37. package/lib/search.d.ts +22 -0
  38. package/lib/search.d.ts.map +1 -0
  39. package/lib/search.js +86 -0
  40. package/lib/search.js.map +1 -0
  41. package/lib/show-options.d.ts +33 -0
  42. package/lib/show-options.d.ts.map +1 -0
  43. package/lib/show-options.js +15 -0
  44. package/lib/show-options.js.map +1 -0
  45. package/lib/show.d.ts +18 -0
  46. package/lib/show.d.ts.map +1 -0
  47. package/lib/show.js +289 -0
  48. package/lib/show.js.map +1 -0
  49. package/lib/table.d.ts +27 -0
  50. package/lib/table.d.ts.map +1 -0
  51. package/lib/table.js +56 -0
  52. package/lib/table.js.map +1 -0
  53. package/lib/trusted-publishing-options.d.ts +30 -0
  54. package/lib/trusted-publishing-options.d.ts.map +1 -0
  55. package/lib/trusted-publishing-options.js +15 -0
  56. package/lib/trusted-publishing-options.js.map +1 -0
  57. package/lib/trusted-publishing.d.ts +35 -0
  58. package/lib/trusted-publishing.d.ts.map +1 -0
  59. package/lib/trusted-publishing.js +112 -0
  60. package/lib/trusted-publishing.js.map +1 -0
  61. package/lib/unpublish-options.d.ts +34 -0
  62. package/lib/unpublish-options.d.ts.map +1 -0
  63. package/lib/unpublish-options.js +15 -0
  64. package/lib/unpublish-options.js.map +1 -0
  65. package/lib/unpublish.d.ts +22 -0
  66. package/lib/unpublish.d.ts.map +1 -0
  67. package/lib/unpublish.js +137 -0
  68. package/lib/unpublish.js.map +1 -0
  69. package/lib/util.d.ts +39 -1
  70. package/lib/util.d.ts.map +1 -1
  71. package/lib/util.js +91 -5
  72. package/lib/util.js.map +1 -1
  73. package/lib/verify-options.d.ts +24 -0
  74. package/lib/verify-options.d.ts.map +1 -0
  75. package/lib/verify-options.js +15 -0
  76. package/lib/verify-options.js.map +1 -0
  77. package/lib/verify-signature-options.d.ts +32 -0
  78. package/lib/verify-signature-options.d.ts.map +1 -0
  79. package/lib/verify-signature-options.js +15 -0
  80. package/lib/verify-signature-options.js.map +1 -0
  81. package/lib/verify-signature.d.ts +28 -0
  82. package/lib/verify-signature.d.ts.map +1 -0
  83. package/lib/verify-signature.js +105 -0
  84. package/lib/verify-signature.js.map +1 -0
  85. package/lib/verify.d.ts +21 -0
  86. package/lib/verify.d.ts.map +1 -0
  87. package/lib/verify.js +97 -0
  88. package/lib/verify.js.map +1 -0
  89. package/lib/version.d.ts +1 -1
  90. package/lib/version.js +1 -1
  91. package/lib/zip.d.ts +10 -0
  92. package/lib/zip.d.ts.map +1 -1
  93. package/lib/zip.js +31 -0
  94. package/lib/zip.js.map +1 -1
  95. package/package.json +9 -5
  96. package/src/get.ts +1 -1
  97. package/src/index.ts +4 -0
  98. package/src/list-options.ts +25 -0
  99. package/src/list.ts +48 -0
  100. package/src/main.ts +89 -3
  101. package/src/oidc.ts +107 -0
  102. package/src/publish-options.ts +8 -1
  103. package/src/publish.ts +97 -5
  104. package/src/registry-options.ts +8 -0
  105. package/src/registry.ts +292 -19
  106. package/src/search-options.ts +49 -0
  107. package/src/search.ts +95 -0
  108. package/src/show-options.ts +34 -0
  109. package/src/show.ts +328 -0
  110. package/src/table.ts +53 -0
  111. package/src/trusted-publishing-options.ts +30 -0
  112. package/src/trusted-publishing.ts +148 -0
  113. package/src/unpublish-options.ts +35 -0
  114. package/src/unpublish.ts +160 -0
  115. package/src/util.ts +102 -5
  116. package/src/verify-options.ts +25 -0
  117. package/src/verify-signature-options.ts +32 -0
  118. package/src/verify-signature.ts +124 -0
  119. package/src/verify.ts +107 -0
  120. package/src/version.ts +1 -1
  121. package/src/zip.ts +31 -1
@@ -0,0 +1,160 @@
1
+ /******************************************************************************
2
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
3
+ *
4
+ * See the NOTICE file(s) distributed with this work for additional
5
+ * information regarding copyright ownership.
6
+ *
7
+ * This program and the accompanying materials are made available under the
8
+ * terms of the Eclipse Public License 2.0 which is available at
9
+ * https://www.eclipse.org/legal/epl-2.0.
10
+ *
11
+ * SPDX-License-Identifier: EPL-2.0
12
+ *****************************************************************************/
13
+
14
+ import { confirm } from '@inquirer/prompts';
15
+ // `import * as isCI from 'is-ci'` would bind to a namespace object instead of the boolean `is-ci`
16
+ // exports whenever the importer applies ES module interop (e.g. a bundler, or vitest's own
17
+ // transform), making the truthiness check below always true. `import = require` always binds
18
+ // directly to the exported value, regardless of how the importing tool handles interop.
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ import isCI = require('is-ci');
21
+ import * as semver from 'semver';
22
+ import { getPAT } from './pat';
23
+ import { Registry, TargetPlatformVersion } from './registry';
24
+ import { UnpublishOptions } from './unpublish-options';
25
+ import { addEnvOptions, matchExtensionId, readManifest } from './util';
26
+
27
+ /**
28
+ * Oldest registry version that exposes the `delete` endpoint used by this command.
29
+ */
30
+ export const MIN_UNPUBLISH_REGISTRY_VERSION = '1.2.0';
31
+
32
+ /**
33
+ * Deletes an extension or some of its versions from the registry.
34
+ */
35
+ export async function unpublish(options: UnpublishOptions = {}): Promise<void> {
36
+ addEnvOptions(options);
37
+ if (options.targets && !options.versions) {
38
+ throw new Error("Please specify the versions to delete with '--versions' when using '--target'.");
39
+ }
40
+
41
+ const extensionId = options.extensionId ?? await readExtensionId();
42
+ const match = matchExtensionId(extensionId);
43
+ if (!match) {
44
+ throw new Error('The extension identifier must have the form `namespace.extension` or `namespace/extension`.');
45
+ }
46
+
47
+ const [, namespace, extension] = match;
48
+ const registry = new Registry(options);
49
+ await ensureUnpublishSupported(registry);
50
+
51
+ options.pat = await getPAT(namespace, { ...options, namespace }, false);
52
+
53
+ const targetVersions = getTargetVersions(options);
54
+ await confirmUnpublish(`${namespace}.${extension}`, targetVersions, registry.url, options.force);
55
+
56
+ const result = await registry.deleteExtension(namespace, extension, targetVersions, options.pat);
57
+ if (result.error) {
58
+ throw new Error(result.error);
59
+ }
60
+ // the registry reports one line per deleted version, and nothing at all if there was nothing to delete
61
+ console.log(`🗑 ${result.success || `Nothing to delete for ${namespace}.${extension}`}`);
62
+ }
63
+
64
+ /**
65
+ * Reads the extension identifier from the `package.json` in the current directory,
66
+ * so that `ovsx unpublish` can be run from an extension folder without arguments.
67
+ */
68
+ async function readExtensionId(): Promise<string> {
69
+ let error;
70
+ try {
71
+ const manifest = await readManifest();
72
+ if (manifest.publisher && manifest.name) {
73
+ return `${manifest.publisher}.${manifest.name}`;
74
+ }
75
+ } catch (e) {
76
+ error = e;
77
+ }
78
+
79
+ throw new Error(
80
+ 'Unable to read the extension identifier. Please supply it as an argument or run ovsx from the extension folder.' +
81
+ (error ? `\n\n${error}` : '')
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Fails fast with an actionable message when the registry is known to be too old to support
87
+ * deleting extensions, rather than letting the delete request fail with a generic 404.
88
+ *
89
+ * The check is best-effort: registries that don't expose `/api/version` at all, or report a
90
+ * version that can't be parsed as semver (e.g. a build without `ovsx.registry.version` set),
91
+ * are assumed to support the endpoint. The delete request itself will surface any real
92
+ * incompatibility in that case.
93
+ */
94
+ async function ensureUnpublishSupported(registry: Registry): Promise<void> {
95
+ let reportedVersion: string | undefined;
96
+ try {
97
+ reportedVersion = (await registry.getRegistryVersion()).version;
98
+ } catch {
99
+ return;
100
+ }
101
+
102
+ // `coerce` drops any prerelease/build suffix (e.g. `1.2.0-dev.0` or `1.2.0-SNAPSHOT` both
103
+ // become `1.2.0`), so development builds of a supported release compare as supported too.
104
+ const version = semver.coerce(reportedVersion);
105
+ if (version && semver.lt(version, MIN_UNPUBLISH_REGISTRY_VERSION)) {
106
+ throw new Error(
107
+ `The registry at ${registry.url} runs version ${reportedVersion}, but deleting extensions requires `
108
+ + `version ${MIN_UNPUBLISH_REGISTRY_VERSION} or later.`
109
+ );
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Returns the versions to delete, or `undefined` to delete the extension as a whole.
115
+ * A version without target platform tells the registry to delete all target platforms
116
+ * of that version.
117
+ */
118
+ function getTargetVersions(options: UnpublishOptions): TargetPlatformVersion[] | undefined {
119
+ if (!options.versions) {
120
+ return undefined;
121
+ }
122
+ if (!options.targets) {
123
+ return options.versions.map(version => ({ version }));
124
+ }
125
+
126
+ return options.versions.flatMap(
127
+ version => options.targets!.map(targetPlatform => ({ version, targetPlatform }))
128
+ );
129
+ }
130
+
131
+ async function confirmUnpublish(
132
+ extensionId: string,
133
+ targetVersions: TargetPlatformVersion[] | undefined,
134
+ registryUrl: string,
135
+ force?: boolean
136
+ ): Promise<void> {
137
+ if (force) {
138
+ return;
139
+ }
140
+ if (isCI || !process.stdin.isTTY) {
141
+ throw new Error("Aborted. Use '--force' to delete without confirmation.");
142
+ }
143
+
144
+ const what = targetVersions
145
+ ? targetVersions.map(target => describe(extensionId, target)).join(', ')
146
+ : `all versions of ${extensionId}`;
147
+ const confirmed = await confirm({
148
+ message: `Delete ${what} from ${registryUrl}? Deleted versions can never be published again.`,
149
+ default: false
150
+ });
151
+
152
+ if (!confirmed) {
153
+ throw new Error('Aborted.');
154
+ }
155
+ }
156
+
157
+ function describe(extensionId: string, target: TargetPlatformVersion): string {
158
+ const version = `${extensionId} v${target.version}`;
159
+ return target.targetPlatform ? `${version}@${target.targetPlatform}` : version;
160
+ }
package/src/util.ts CHANGED
@@ -13,6 +13,7 @@ import * as path from 'path';
13
13
  import * as tmp from 'tmp';
14
14
  import * as http from 'http';
15
15
  import { RegistryOptions } from './registry-options';
16
+ import { TrustedPublishingOptions } from './trusted-publishing-options';
16
17
 
17
18
  export { promisify } from 'util';
18
19
 
@@ -21,6 +22,54 @@ export function addEnvOptions(options: RegistryOptions): void {
21
22
  options.pat ??= process.env.OVSX_PAT;
22
23
  options.username ??= process.env.OVSX_USERNAME;
23
24
  options.password ??= process.env.OVSX_PASSWORD;
25
+ options.timeout ??= parseIntEnv(process.env.OVSX_TIMEOUT);
26
+ }
27
+
28
+ export function addTrustedPublishingEnvOptions(options: TrustedPublishingOptions): void {
29
+ options.trustedPublishing ??= parseBooleanEnv(process.env.OVSX_TRUSTED_PUBLISHING);
30
+ options.idToken ??= process.env.OVSX_ID_TOKEN;
31
+ options.oidcAudience ??= process.env.OVSX_OIDC_AUDIENCE;
32
+ }
33
+
34
+ /** How long a request may make no progress before it is abandoned; see RegistryOptions.timeout. */
35
+ export const DEFAULT_TIMEOUT = 30_000;
36
+
37
+ /**
38
+ * The configured request timeout, for requests that have no RegistryOptions to read it from - the CI
39
+ * token exchange deliberately shares nothing with the registry, but there is no reason for it to be
40
+ * on a different clock.
41
+ */
42
+ export function configuredTimeout(): number {
43
+ return parseIntEnv(process.env.OVSX_TIMEOUT) ?? DEFAULT_TIMEOUT;
44
+ }
45
+
46
+ function parseIntEnv(value?: string): number | undefined {
47
+ if (value === undefined || value.trim().length === 0) {
48
+ return undefined;
49
+ }
50
+ const parsed = Number(value.trim());
51
+ // Left undefined rather than throwing, so a mistyped variable falls back to the default instead
52
+ // of stopping a command that would otherwise work.
53
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
54
+ }
55
+
56
+ function parseBooleanEnv(value?: string): boolean | undefined {
57
+ if (value === undefined || value.trim().length === 0) {
58
+ return undefined;
59
+ }
60
+ return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
61
+ }
62
+
63
+ /**
64
+ * Parses a commander option that must be a non-negative whole number, so a typo is reported by the
65
+ * CLI rather than sent to the registry as a bad request.
66
+ */
67
+ export function parseNonNegativeInt(value: string): number {
68
+ const parsed = Number(value);
69
+ if (!Number.isInteger(parsed) || parsed < 0) {
70
+ throw new Error(`Expected a non-negative whole number, got '${value}'.`);
71
+ }
72
+ return parsed;
24
73
  }
25
74
 
26
75
  export function matchExtensionId(id: string): RegExpExecArray | null {
@@ -85,11 +134,59 @@ export function handleError(debug?: boolean, additionalMessage?: string, exit: b
85
134
  };
86
135
  }
87
136
 
88
- export function statusError(response: http.IncomingMessage): Error {
89
- if (response.statusMessage)
90
- return new Error(`The server responded with status ${response.statusCode}: ${response.statusMessage}`);
91
- else
92
- return new Error(`The server responded with status ${response.statusCode}.`);
137
+ /**
138
+ * Formats a byte count for display, e.g. `1536` -> `1.5 KB`. Mirrors the registry's own
139
+ * `FileUtils.byteCountToDisplaySize` formatting so client- and server-side size limit messages read
140
+ * the same way.
141
+ */
142
+ export function formatBytes(bytes: number): string {
143
+ const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
144
+ let size = bytes;
145
+ let unit = 0;
146
+ while (size >= 1024 && unit < units.length - 1) {
147
+ size /= 1024;
148
+ unit++;
149
+ }
150
+ return `${Math.floor(size)} ${units[unit]}`;
151
+ }
152
+
153
+ /**
154
+ * An error carrying the HTTP status that produced it, so a caller can tell "the registry refused this"
155
+ * from "the registry could not answer right now" without matching on the message.
156
+ */
157
+ export interface StatusError extends Error {
158
+ status?: number;
159
+ }
160
+
161
+ export function withStatus(error: Error, status?: number): StatusError {
162
+ const withStatusCode = error as StatusError;
163
+ withStatusCode.status = status;
164
+ return withStatusCode;
165
+ }
166
+
167
+ /**
168
+ * A URL in the form it can safely be printed: no user info and no query string.
169
+ *
170
+ * `createNamespace`, `verifyPat`, `publish` and `delete` all put the personal access token in a
171
+ * `token` query parameter, and an error message goes straight to stderr and from there into CI logs.
172
+ * The query carries nothing an error needs, so it is dropped whole rather than filtered key by key -
173
+ * a filter has to be kept in step with every parameter anyone ever adds.
174
+ */
175
+ export function redactUrl(url: URL | string): string {
176
+ try {
177
+ const parsed = typeof url === 'string' ? new URL(url) : url;
178
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
179
+ } catch {
180
+ // Not parseable, so nothing can be assumed about what it holds.
181
+ return '<url>';
182
+ }
183
+ }
184
+
185
+ export function statusError(response: http.IncomingMessage): StatusError {
186
+ const message = response.statusMessage
187
+ ? `The server responded with status ${response.statusCode}: ${response.statusMessage}`
188
+ : `The server responded with status ${response.statusCode}.`;
189
+ return withStatus(new Error(message), response.statusCode);
93
190
  }
94
191
 
95
192
  export function readFile(name: string, packagePath?: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
@@ -0,0 +1,25 @@
1
+ /******************************************************************************
2
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
3
+ *
4
+ * See the NOTICE file(s) distributed with this work for additional
5
+ * information regarding copyright ownership.
6
+ *
7
+ * This program and the accompanying materials are made available under the
8
+ * terms of the Eclipse Public License 2.0 which is available at
9
+ * https://www.eclipse.org/legal/epl-2.0
10
+ *
11
+ * SPDX-License-Identifier: EPL-2.0
12
+ *****************************************************************************/
13
+
14
+ import { RegistryOptions } from './registry-options';
15
+
16
+ export interface VerifyOptions extends RegistryOptions {
17
+ /**
18
+ * Path to the downloaded .vsix package to verify.
19
+ */
20
+ packagePath: string;
21
+ /**
22
+ * Target platform. Defaults to universal.
23
+ */
24
+ target?: string;
25
+ }
@@ -0,0 +1,32 @@
1
+ /******************************************************************************
2
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
3
+ *
4
+ * See the NOTICE file(s) distributed with this work for additional
5
+ * information regarding copyright ownership.
6
+ *
7
+ * This program and the accompanying materials are made available under the
8
+ * terms of the Eclipse Public License 2.0 which is available at
9
+ * https://www.eclipse.org/legal/epl-2.0
10
+ *
11
+ * SPDX-License-Identifier: EPL-2.0
12
+ *****************************************************************************/
13
+
14
+ export interface VerifySignatureOptions {
15
+ /**
16
+ * Path to the .vsix package.
17
+ */
18
+ packagePath: string;
19
+ /**
20
+ * Path to the signature manifest file (the `.signature.manifest` entry of a registry's
21
+ * signature archive).
22
+ */
23
+ manifestPath: string;
24
+ /**
25
+ * Path to the signature file (the `.signature.sig` entry of a registry's signature archive).
26
+ */
27
+ signaturePath: string;
28
+ /**
29
+ * Path to the registry's public key file, in PEM format.
30
+ */
31
+ publicKeyPath: string;
32
+ }
@@ -0,0 +1,124 @@
1
+ /******************************************************************************
2
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
3
+ *
4
+ * See the NOTICE file(s) distributed with this work for additional
5
+ * information regarding copyright ownership.
6
+ *
7
+ * This program and the accompanying materials are made available under the
8
+ * terms of the Eclipse Public License 2.0 which is available at
9
+ * https://www.eclipse.org/legal/epl-2.0
10
+ *
11
+ * SPDX-License-Identifier: EPL-2.0
12
+ *****************************************************************************/
13
+
14
+ import * as crypto from 'crypto';
15
+ import * as fs from 'fs';
16
+ import { hashAllZipEntries } from './zip';
17
+ import { VerifySignatureOptions } from './verify-signature-options';
18
+
19
+ interface DigestEntry {
20
+ size?: number;
21
+ digests?: { sha256?: string };
22
+ }
23
+
24
+ interface SignatureManifest {
25
+ package?: DigestEntry;
26
+ entries?: { [base64Name: string]: DigestEntry };
27
+ }
28
+
29
+ /**
30
+ * Verifies a package/manifest/signature file trio entirely offline, the same way `vsce
31
+ * verify-signature` does - no registry involved, every input is a local file. Unlike `verify`,
32
+ * which fetches everything it needs from a registry for a package identified by name, this is for
33
+ * a signature archive you already extracted yourself (e.g. the `.sigzip` a registry serves has a
34
+ * `.signature.sig` and a `.signature.manifest` entry, matching `--signaturePath` and
35
+ * `--manifestPath` here).
36
+ *
37
+ * `vsce verify-signature` validates against Microsoft's own (closed-source) signing scheme and
38
+ * has no need for a public key argument - VS Code's Marketplace keys are baked into its verifier.
39
+ * Open VSX's registries each hold their own Ed25519 key pair instead, so `--publicKeyPath` is
40
+ * required here to say which registry's signature this is supposed to be.
41
+ */
42
+ export async function verifySignature(options: VerifySignatureOptions): Promise<void> {
43
+ const [packageBytes, manifestJson, signature, publicKeyPem] = await Promise.all([
44
+ fs.promises.readFile(options.packagePath),
45
+ fs.promises.readFile(options.manifestPath, 'utf-8'),
46
+ fs.promises.readFile(options.signaturePath),
47
+ fs.promises.readFile(options.publicKeyPath, 'utf-8')
48
+ ]);
49
+
50
+ let manifest: SignatureManifest;
51
+ try {
52
+ manifest = JSON.parse(manifestJson);
53
+ } catch (err) {
54
+ throw new Error(`Failed to parse the manifest file as JSON: ${err instanceof Error ? err.message : err}`);
55
+ }
56
+
57
+ const publicKey = crypto.createPublicKey({ key: publicKeyPem, format: 'pem' });
58
+ const signatureValid = crypto.verify(null, packageBytes, publicKey, signature);
59
+ if (!signatureValid) {
60
+ throw new Error('Signature verification failed. The package may have been tampered with.');
61
+ }
62
+
63
+ const manifestIssues = await checkManifest(options.packagePath, packageBytes, manifest);
64
+ if (manifestIssues.length > 0) {
65
+ throw new Error(
66
+ 'The signature is valid, but the package does not match the manifest:\n'
67
+ + manifestIssues.map(issue => ` - ${issue}`).join('\n')
68
+ );
69
+ }
70
+
71
+ console.log('✅ Signature is valid.');
72
+ }
73
+
74
+ function sha256Base64(content: Buffer): string {
75
+ return crypto.createHash('sha256').update(content).digest('base64');
76
+ }
77
+
78
+ /**
79
+ * The manifest itself isn't part of what the signature covers - only the raw package bytes are
80
+ * signed - so this can't add cryptographic trust on its own. It does confirm the manifest wasn't
81
+ * corrupted or is stale relative to the actual package, and unlike the signature check alone, can
82
+ * point at exactly which entry doesn't match.
83
+ */
84
+ async function checkManifest(packagePath: string, packageBytes: Buffer, manifest: SignatureManifest): Promise<string[]> {
85
+ const issues: string[] = [];
86
+
87
+ const packageDigest = manifest.package?.digests?.sha256;
88
+ if (packageDigest === undefined) {
89
+ issues.push('the manifest does not record a package digest');
90
+ } else if (packageDigest !== sha256Base64(packageBytes)) {
91
+ issues.push('the package digest does not match the manifest');
92
+ }
93
+
94
+ const manifestEntries = manifest.entries ?? {};
95
+ const seenKeys = new Set<string>();
96
+
97
+ // Streamed straight into a digest one entry at a time, rather than buffering every entry's
98
+ // full content into memory alongside the already-loaded package bytes.
99
+ const packageEntryDigests = await hashAllZipEntries(packagePath);
100
+ for (const [name, actualDigest] of packageEntryDigests) {
101
+ const key = Buffer.from(name, 'utf-8').toString('base64');
102
+ seenKeys.add(key);
103
+ const entry = manifestEntries[key];
104
+ if (!entry) {
105
+ issues.push(`'${name}' is present in the package but missing from the manifest`);
106
+ continue;
107
+ }
108
+ const entryDigest = entry.digests?.sha256;
109
+ if (entryDigest === undefined) {
110
+ issues.push(`'${name}' has no recorded digest in the manifest`);
111
+ } else if (entryDigest !== actualDigest) {
112
+ issues.push(`'${name}' does not match its digest in the manifest`);
113
+ }
114
+ }
115
+
116
+ for (const key of Object.keys(manifestEntries)) {
117
+ if (!seenKeys.has(key)) {
118
+ const name = Buffer.from(key, 'base64').toString('utf-8');
119
+ issues.push(`'${name}' is listed in the manifest but missing from the package`);
120
+ }
121
+ }
122
+
123
+ return issues;
124
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,107 @@
1
+ /******************************************************************************
2
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
3
+ *
4
+ * See the NOTICE file(s) distributed with this work for additional
5
+ * information regarding copyright ownership.
6
+ *
7
+ * This program and the accompanying materials are made available under the
8
+ * terms of the Eclipse Public License 2.0 which is available at
9
+ * https://www.eclipse.org/legal/epl-2.0
10
+ *
11
+ * SPDX-License-Identifier: EPL-2.0
12
+ *****************************************************************************/
13
+
14
+ import * as crypto from 'crypto';
15
+ import * as fs from 'fs';
16
+ import { Registry, Extension } from './registry';
17
+ import { addEnvOptions, createTempFile } from './util';
18
+ import { readVSIXPackage, readZip } from './zip';
19
+ import { VerifyOptions } from './verify-options';
20
+
21
+ const SIGNATURE_ENTRY = '.signature.sig';
22
+
23
+ /**
24
+ * Verifies a downloaded .vsix package's signature against the registry's public key, the same way
25
+ * VS Code itself verifies signed packages on install. Mirrors `vsce verify-signature`, except the
26
+ * signature and public key are fetched from the registry instead of being passed in as separate
27
+ * files - the registry already exposes both for any published, signed version.
28
+ */
29
+ export async function verify(options: VerifyOptions): Promise<void> {
30
+ addEnvOptions(options);
31
+ const registry = new Registry(options);
32
+
33
+ const manifest = await readVSIXPackage(options.packagePath);
34
+ const namespace = manifest.publisher;
35
+ const extensionName = manifest.name;
36
+
37
+ const latest = await registry.getMetadata(namespace, extensionName, options.target);
38
+ if (latest.error) {
39
+ throw new Error(latest.error);
40
+ }
41
+
42
+ const extension = await resolveVersion(registry, latest, manifest.version);
43
+ const description = `${namespace}.${extensionName} v${extension.version}`;
44
+
45
+ const signatureUrl = extension.files.signature;
46
+ const publicKeyUrl = extension.files.publicKey;
47
+ if (!signatureUrl || !publicKeyUrl) {
48
+ throw new Error(`${description} is not signed by the registry - there is nothing to verify.`);
49
+ }
50
+
51
+ const [signature, publicKeyPem] = await Promise.all([
52
+ downloadSignature(registry, signatureUrl),
53
+ downloadPublicKey(registry, publicKeyUrl)
54
+ ]);
55
+
56
+ const packageBytes = await fs.promises.readFile(options.packagePath);
57
+ const publicKey = crypto.createPublicKey({ key: publicKeyPem, format: 'pem' });
58
+ const verified = crypto.verify(null, packageBytes, publicKey, signature);
59
+
60
+ if (!verified) {
61
+ throw new Error(
62
+ `${description} does not match the version published to ${registry.url}. `
63
+ + 'The package may have been tampered with, or did not come from that registry.'
64
+ );
65
+ }
66
+ console.log(`✅ This package is identical to ${description} as published to ${registry.url}.`);
67
+ }
68
+
69
+ /**
70
+ * `registry.getMetadata` always returns the latest version. Follow `allVersions` to the exact
71
+ * version recorded in the package's own manifest when it differs.
72
+ */
73
+ async function resolveVersion(registry: Registry, extension: Extension, version: string): Promise<Extension> {
74
+ if (extension.version === version) {
75
+ return extension;
76
+ }
77
+ const versionUrl = extension.allVersions[version];
78
+ if (!versionUrl) {
79
+ throw new Error(`${extension.namespace}.${extension.name} has no published version '${version}'.`);
80
+ }
81
+ return registry.getJson(new URL(versionUrl));
82
+ }
83
+
84
+ async function downloadSignature(registry: Registry, url: string): Promise<Buffer> {
85
+ const sigzipPath = await createTempFile({ postfix: '.sigzip' });
86
+ try {
87
+ await registry.download(sigzipPath, new URL(url));
88
+ const entries = await readZip(sigzipPath, name => name === SIGNATURE_ENTRY);
89
+ const signature = entries.get(SIGNATURE_ENTRY);
90
+ if (!signature) {
91
+ throw new Error(`The signature archive does not contain a '${SIGNATURE_ENTRY}' entry.`);
92
+ }
93
+ return signature;
94
+ } finally {
95
+ await fs.promises.rm(sigzipPath, { force: true });
96
+ }
97
+ }
98
+
99
+ async function downloadPublicKey(registry: Registry, url: string): Promise<string> {
100
+ const publicKeyPath = await createTempFile({ postfix: '.pem' });
101
+ try {
102
+ await registry.download(publicKeyPath, new URL(url));
103
+ return await fs.promises.readFile(publicKeyPath, 'utf-8');
104
+ } finally {
105
+ await fs.promises.rm(publicKeyPath, { force: true });
106
+ }
107
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const LIB_VERSION = "1.1.0";
1
+ export const LIB_VERSION = "1.2.0";
package/src/zip.ts CHANGED
@@ -7,8 +7,10 @@
7
7
  *
8
8
  * SPDX-License-Identifier: EPL-2.0
9
9
  ********************************************************************************/
10
+ import * as crypto from 'crypto';
10
11
  import * as yauzl from 'yauzl-promise';
11
12
  import { Readable } from 'stream';
13
+ import { pipeline } from 'stream/promises';
12
14
  import { Manifest } from './util';
13
15
 
14
16
  async function bufferStream(stream: Readable): Promise<Buffer> {
@@ -20,7 +22,7 @@ async function bufferStream(stream: Readable): Promise<Buffer> {
20
22
  });
21
23
  }
22
24
 
23
- async function readZip(packagePath: string, filter: (name: string) => boolean): Promise<Map<string, Buffer>> {
25
+ export async function readZip(packagePath: string, filter: (name: string) => boolean): Promise<Map<string, Buffer>> {
24
26
  const result = new Map<string, Buffer>();
25
27
  const zipfile = await yauzl.open(packagePath);
26
28
  try {
@@ -39,6 +41,34 @@ async function readZip(packagePath: string, filter: (name: string) => boolean):
39
41
  return result;
40
42
  }
41
43
 
44
+ /**
45
+ * Computes the SHA256 digest of every non-directory entry of a zip file, keyed by its exact
46
+ * (case-preserving) name - unlike {@link readZip}, which lowercases names for its
47
+ * case-insensitive-filter callers. Used where entry names and digests are compared against another
48
+ * source of truth, such as a signature manifest that records the original names, without also
49
+ * having to hold every entry's full content in memory at once alongside it - each entry's content
50
+ * is streamed straight into a hash and discarded, one entry at a time.
51
+ */
52
+ export async function hashAllZipEntries(packagePath: string): Promise<Map<string, string>> {
53
+ const result = new Map<string, string>();
54
+ const zipfile = await yauzl.open(packagePath);
55
+ try {
56
+ for await (const entry of zipfile) {
57
+ if (entry.filename.endsWith('/')) {
58
+ continue;
59
+ }
60
+ const stream = await zipfile.openReadStream(entry);
61
+ const hash = crypto.createHash('sha256');
62
+ await pipeline(stream, hash);
63
+ result.set(entry.filename, hash.digest('base64'));
64
+ }
65
+ } finally {
66
+ await zipfile.close();
67
+ }
68
+
69
+ return result;
70
+ }
71
+
42
72
  export async function readVSIXPackage(packagePath: string): Promise<Manifest> {
43
73
  const map = await readZip(packagePath, name => /^extension\/package\.json$/i.test(name));
44
74
  const rawManifest = map.get('extension/package.json');