@ttsc/playground 0.20.1 → 0.22.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 +8 -0
- package/lib/src/npm/installPlaygroundDependencies.js +31 -11
- package/lib/src/npm/installPlaygroundDependencies.js.map +1 -1
- package/lib/src/npm/internal/npmRegistry.d.ts +15 -2
- package/lib/src/npm/internal/npmRegistry.js +332 -27
- package/lib/src/npm/internal/npmRegistry.js.map +1 -1
- package/lib/src/sandbox/createSandboxRequire.js +254 -105
- package/lib/src/sandbox/createSandboxRequire.js.map +1 -1
- package/lib/src/structures/IPlaygroundDependencyInstallOptions.d.ts +13 -0
- package/package.json +3 -3
- package/src/npm/installPlaygroundDependencies.ts +42 -16
- package/src/npm/internal/npmRegistry.ts +431 -31
- package/src/sandbox/createSandboxRequire.ts +313 -136
- package/src/structures/IPlaygroundDependencyInstallOptions.ts +13 -0
|
@@ -15,9 +15,13 @@ import {
|
|
|
15
15
|
throwIfAborted,
|
|
16
16
|
toTypesPackageName,
|
|
17
17
|
unpackNpmTarball,
|
|
18
|
+
validateNpmByteLimit,
|
|
19
|
+
verifyTarball,
|
|
18
20
|
} from "./internal/npmRegistry";
|
|
19
21
|
|
|
20
22
|
const DEFAULT_MAX_PACKAGES = 48;
|
|
23
|
+
const DEFAULT_MAX_TARBALL_BYTES = 16 * 1024 * 1024;
|
|
24
|
+
const DEFAULT_MAX_UNPACKED_BYTES = 64 * 1024 * 1024;
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* Resolve, download, and unpack a set of npm packages directly inside the
|
|
@@ -72,6 +76,11 @@ export async function installPlaygroundDependencies(
|
|
|
72
76
|
});
|
|
73
77
|
}
|
|
74
78
|
const maxPackages = options.maxPackages ?? DEFAULT_MAX_PACKAGES;
|
|
79
|
+
const maxTarballBytes = options.maxTarballBytes ?? DEFAULT_MAX_TARBALL_BYTES;
|
|
80
|
+
const maxUnpackedBytes =
|
|
81
|
+
options.maxUnpackedBytes ?? DEFAULT_MAX_UNPACKED_BYTES;
|
|
82
|
+
validateNpmByteLimit(maxTarballBytes, "compressed");
|
|
83
|
+
validateNpmByteLimit(maxUnpackedBytes, "expanded");
|
|
75
84
|
const queue: IQueueItem[] = [];
|
|
76
85
|
const queued = new Map<string, IQueueItem>();
|
|
77
86
|
const done = new Map<string, string>();
|
|
@@ -190,12 +199,18 @@ export async function installPlaygroundDependencies(
|
|
|
190
199
|
throwIfAborted(options.signal);
|
|
191
200
|
|
|
192
201
|
report("resolve", item, `Resolving ${item.name}`);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
202
|
+
let metadata: Awaited<ReturnType<typeof fetchNpmMetadata>>;
|
|
203
|
+
try {
|
|
204
|
+
metadata = await fetchNpmMetadata(
|
|
205
|
+
fetchImpl,
|
|
206
|
+
item.registryName ?? item.name,
|
|
207
|
+
item.optional,
|
|
208
|
+
options.signal,
|
|
209
|
+
);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
throwIfAborted(options.signal);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
199
214
|
throwIfAborted(options.signal);
|
|
200
215
|
if (!metadata) {
|
|
201
216
|
const mounted = installedDependencies.get(item.name);
|
|
@@ -250,19 +265,30 @@ export async function installPlaygroundDependencies(
|
|
|
250
265
|
const versionMetadata = metadata.versions[version];
|
|
251
266
|
const tarball = versionMetadata?.dist?.tarball;
|
|
252
267
|
if (!versionMetadata || !tarball) {
|
|
253
|
-
if (item.optional) {
|
|
254
|
-
done.set(item.name, "");
|
|
255
|
-
report("skip", item, `Skipped optional ${item.name}`);
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
268
|
throw new Error(`No tarball found for ${item.name}@${version}.`);
|
|
259
269
|
}
|
|
260
270
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
271
|
+
let unpacked: Awaited<ReturnType<typeof unpackNpmTarball>>;
|
|
272
|
+
try {
|
|
273
|
+
report("download", item, `Downloading ${item.name}@${version}`, version);
|
|
274
|
+
const tgz = await downloadTarball(
|
|
275
|
+
fetchImpl,
|
|
276
|
+
tarball,
|
|
277
|
+
options.signal,
|
|
278
|
+
maxTarballBytes,
|
|
279
|
+
);
|
|
280
|
+
throwIfAborted(options.signal);
|
|
281
|
+
await verifyTarball(tgz, versionMetadata.dist ?? {}, options.signal);
|
|
282
|
+
throwIfAborted(options.signal);
|
|
283
|
+
report("extract", item, `Extracting ${item.name}@${version}`, version);
|
|
284
|
+
unpacked = await unpackNpmTarball(tgz, options.signal, maxUnpackedBytes);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throwIfAborted(options.signal);
|
|
287
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
288
|
+
throw new Error(`Failed to install ${item.name}@${version}: ${message}`, {
|
|
289
|
+
cause: error,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
266
292
|
throwIfAborted(options.signal);
|
|
267
293
|
const packageJson = {
|
|
268
294
|
...versionMetadata,
|
|
@@ -22,6 +22,8 @@ export interface INpmVersionMetadata {
|
|
|
22
22
|
peerDependencies?: Record<string, string>;
|
|
23
23
|
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
|
24
24
|
dist?: {
|
|
25
|
+
integrity?: string;
|
|
26
|
+
shasum?: string;
|
|
25
27
|
tarball?: string;
|
|
26
28
|
};
|
|
27
29
|
}
|
|
@@ -57,6 +59,11 @@ export type FetchLike = (
|
|
|
57
59
|
init?: RequestInit,
|
|
58
60
|
) => Promise<Response>;
|
|
59
61
|
|
|
62
|
+
declare const VALIDATED_NPM_BYTE_LIMIT: unique symbol;
|
|
63
|
+
type ValidatedNpmByteLimit = number & {
|
|
64
|
+
readonly [VALIDATED_NPM_BYTE_LIMIT]: true;
|
|
65
|
+
};
|
|
66
|
+
|
|
60
67
|
const TEXT_FILE_REGEXP =
|
|
61
68
|
/(^package\.json$|\.([cm]?js|jsx|[cm]?ts|tsx|json)$|\.d\.[cm]?ts$)/i;
|
|
62
69
|
export const DECLARATION_FILE_REGEXP = /\.d\.[cm]?ts$/i;
|
|
@@ -74,22 +81,38 @@ export async function fetchNpmMetadata(
|
|
|
74
81
|
optional: boolean,
|
|
75
82
|
signal: AbortSignal | undefined,
|
|
76
83
|
): Promise<INpmMetadata | null> {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
throwIfAborted(signal);
|
|
85
|
+
const response = await fetchWithAbort(
|
|
86
|
+
() =>
|
|
87
|
+
fetchImpl(
|
|
88
|
+
`https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
|
|
89
|
+
{
|
|
90
|
+
headers: {
|
|
91
|
+
Accept: "application/vnd.npm.install-v1+json, application/json",
|
|
92
|
+
},
|
|
93
|
+
signal,
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
signal,
|
|
85
97
|
);
|
|
86
|
-
|
|
98
|
+
throwIfResponseAborted(response, signal);
|
|
99
|
+
if (response.status === 404 && optional) {
|
|
100
|
+
cancelResponseBody(response);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
87
103
|
if (!response.ok) {
|
|
104
|
+
cancelResponseBody(response);
|
|
88
105
|
throw new Error(
|
|
89
106
|
`npm registry returned ${response.status} while resolving ${packageName}.`,
|
|
90
107
|
);
|
|
91
108
|
}
|
|
92
|
-
|
|
109
|
+
throwIfAborted(signal);
|
|
110
|
+
const metadata = (await abortable(
|
|
111
|
+
() => response.json() as Promise<INpmMetadata>,
|
|
112
|
+
signal,
|
|
113
|
+
)) as INpmMetadata;
|
|
114
|
+
throwIfAborted(signal);
|
|
115
|
+
return metadata;
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
export function selectVersion(
|
|
@@ -135,20 +158,87 @@ export async function downloadTarball(
|
|
|
135
158
|
fetchImpl: FetchLike,
|
|
136
159
|
tarball: string,
|
|
137
160
|
signal: AbortSignal | undefined,
|
|
161
|
+
maxBytes = 16 * 1024 * 1024,
|
|
138
162
|
): Promise<ArrayBuffer> {
|
|
139
|
-
|
|
163
|
+
throwIfAborted(signal);
|
|
164
|
+
const byteLimit = validateNpmByteLimit(maxBytes, "compressed");
|
|
165
|
+
const response = await fetchWithAbort(
|
|
166
|
+
() => fetchImpl(tarball, { signal }),
|
|
167
|
+
signal,
|
|
168
|
+
);
|
|
169
|
+
throwIfResponseAborted(response, signal);
|
|
140
170
|
if (!response.ok) {
|
|
171
|
+
cancelResponseBody(response);
|
|
141
172
|
throw new Error(`tarball download failed with HTTP ${response.status}.`);
|
|
142
173
|
}
|
|
143
|
-
|
|
174
|
+
const declaredLength = response.headers.get("content-length");
|
|
175
|
+
if (declaredLength !== null) {
|
|
176
|
+
const parsed = Number(declaredLength);
|
|
177
|
+
if (Number.isFinite(parsed) && parsed >= 0 && parsed > byteLimit) {
|
|
178
|
+
cancelResponseBody(response);
|
|
179
|
+
throw new Error(
|
|
180
|
+
`tarball exceeds the ${formatByteLimit(byteLimit)} compressed byte limit.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return collectBoundedStream(
|
|
185
|
+
response.body,
|
|
186
|
+
byteLimit,
|
|
187
|
+
"compressed",
|
|
188
|
+
signal,
|
|
189
|
+
() => response.arrayBuffer(),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Verify registry authentication metadata against the compressed bytes. */
|
|
194
|
+
export async function verifyTarball(
|
|
195
|
+
tgz: ArrayBuffer,
|
|
196
|
+
dist: { integrity?: string; shasum?: string },
|
|
197
|
+
signal: AbortSignal | undefined,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
throwIfAborted(signal);
|
|
200
|
+
if (dist.integrity !== undefined) {
|
|
201
|
+
const candidates = parseIntegrity(dist.integrity);
|
|
202
|
+
const strength = Math.max(...candidates.map(({ rank }) => rank));
|
|
203
|
+
const strongest = candidates.filter(
|
|
204
|
+
(candidate) => candidate.rank === strength,
|
|
205
|
+
);
|
|
206
|
+
const actual = new Uint8Array(
|
|
207
|
+
await abortable(
|
|
208
|
+
() => crypto.subtle.digest(strongest[0]!.webAlgorithm, tgz),
|
|
209
|
+
signal,
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
throwIfAborted(signal);
|
|
213
|
+
if (!strongest.some(({ digest }) => equalBytes(actual, digest))) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`tarball integrity mismatch (${strongest[0]!.algorithm}).`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (dist.shasum !== undefined) {
|
|
221
|
+
if (!/^[a-fA-F0-9]{40}$/.test(dist.shasum)) {
|
|
222
|
+
throw new Error("tarball shasum is not a valid SHA-1 digest.");
|
|
223
|
+
}
|
|
224
|
+
const actual = new Uint8Array(
|
|
225
|
+
await abortable(() => crypto.subtle.digest("SHA-1", tgz), signal),
|
|
226
|
+
);
|
|
227
|
+
throwIfAborted(signal);
|
|
228
|
+
if (!equalBytes(actual, decodeHex(dist.shasum))) {
|
|
229
|
+
throw new Error("tarball shasum mismatch (sha1).");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
144
232
|
}
|
|
145
233
|
|
|
146
234
|
export async function unpackNpmTarball(
|
|
147
235
|
tgz: ArrayBuffer,
|
|
148
236
|
signal: AbortSignal | undefined,
|
|
237
|
+
maxBytes = 64 * 1024 * 1024,
|
|
149
238
|
): Promise<IUnpackedPackage> {
|
|
150
239
|
throwIfAborted(signal);
|
|
151
|
-
const
|
|
240
|
+
const byteLimit = validateNpmByteLimit(maxBytes, "expanded");
|
|
241
|
+
const tar = await gunzip(tgz, byteLimit, signal);
|
|
152
242
|
throwIfAborted(signal);
|
|
153
243
|
const decoder = new TextDecoder();
|
|
154
244
|
const files: Record<string, string> = {};
|
|
@@ -156,24 +246,47 @@ export async function unpackNpmTarball(
|
|
|
156
246
|
let offset = 0;
|
|
157
247
|
let longPath: string | null = null;
|
|
158
248
|
let paxPath: string | null = null;
|
|
249
|
+
let terminated = false;
|
|
250
|
+
let archiveRoot: string | null = null;
|
|
251
|
+
|
|
252
|
+
const confine = (rawPath: string): string => {
|
|
253
|
+
const confined = confineTarPath(rawPath, archiveRoot);
|
|
254
|
+
archiveRoot = confined.root;
|
|
255
|
+
return confined.relative;
|
|
256
|
+
};
|
|
159
257
|
|
|
160
|
-
while (offset
|
|
258
|
+
while (offset < tar.length) {
|
|
161
259
|
throwIfAborted(signal);
|
|
260
|
+
if (offset + 512 > tar.length) {
|
|
261
|
+
throw new Error("Truncated tar header.");
|
|
262
|
+
}
|
|
162
263
|
const header = tar.subarray(offset, offset + 512);
|
|
163
264
|
offset += 512;
|
|
164
|
-
if (header.every((value) => value === 0))
|
|
265
|
+
if (header.every((value) => value === 0)) {
|
|
266
|
+
terminated = true;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
165
269
|
|
|
166
270
|
const type = String.fromCharCode(header[156] ?? 0);
|
|
167
271
|
const size = parseOctal(header.subarray(124, 136));
|
|
272
|
+
if (size > tar.length - offset) {
|
|
273
|
+
throw new Error("Tar entry body extends beyond the archive.");
|
|
274
|
+
}
|
|
168
275
|
const body = tar.subarray(offset, offset + size);
|
|
169
|
-
|
|
276
|
+
const paddedSize = Math.ceil(size / 512) * 512;
|
|
277
|
+
if (!Number.isSafeInteger(paddedSize) || paddedSize > tar.length - offset) {
|
|
278
|
+
throw new Error("Tar entry padding extends beyond the archive.");
|
|
279
|
+
}
|
|
280
|
+
offset += paddedSize;
|
|
170
281
|
|
|
171
282
|
if (type === "L") {
|
|
172
283
|
longPath = trimNull(decoder.decode(body));
|
|
284
|
+
confine(longPath);
|
|
173
285
|
continue;
|
|
174
286
|
}
|
|
175
287
|
if (type === "x") {
|
|
176
288
|
paxPath = parsePaxPath(body);
|
|
289
|
+
if (paxPath !== null) confine(paxPath);
|
|
177
290
|
continue;
|
|
178
291
|
}
|
|
179
292
|
if (type !== "0" && type !== "\0") {
|
|
@@ -182,12 +295,11 @@ export async function unpackNpmTarball(
|
|
|
182
295
|
continue;
|
|
183
296
|
}
|
|
184
297
|
|
|
185
|
-
const rawPath =
|
|
186
|
-
paxPath ?? longPath ?? readTarString(header.subarray(0, 100), decoder);
|
|
298
|
+
const rawPath = paxPath ?? longPath ?? readTarHeaderPath(header, decoder);
|
|
187
299
|
longPath = null;
|
|
188
300
|
paxPath = null;
|
|
189
|
-
const rel =
|
|
190
|
-
if (!
|
|
301
|
+
const rel = confine(rawPath);
|
|
302
|
+
if (!TEXT_FILE_REGEXP.test(rel)) continue;
|
|
191
303
|
|
|
192
304
|
const text = decoder.decode(body);
|
|
193
305
|
files[rel] = text;
|
|
@@ -199,11 +311,16 @@ export async function unpackNpmTarball(
|
|
|
199
311
|
}
|
|
200
312
|
}
|
|
201
313
|
}
|
|
314
|
+
if (!terminated) throw new Error("Tar archive has no end marker.");
|
|
202
315
|
|
|
203
316
|
return { files, packageJson };
|
|
204
317
|
}
|
|
205
318
|
|
|
206
|
-
async function gunzip(
|
|
319
|
+
async function gunzip(
|
|
320
|
+
input: ArrayBuffer,
|
|
321
|
+
maxBytes: ValidatedNpmByteLimit,
|
|
322
|
+
signal: AbortSignal | undefined,
|
|
323
|
+
): Promise<Uint8Array> {
|
|
207
324
|
if (!("DecompressionStream" in globalThis)) {
|
|
208
325
|
throw new Error(
|
|
209
326
|
"This browser cannot unpack npm tgz files because DecompressionStream is unavailable.",
|
|
@@ -212,7 +329,11 @@ async function gunzip(input: ArrayBuffer): Promise<Uint8Array> {
|
|
|
212
329
|
const stream = new Blob([input])
|
|
213
330
|
.stream()
|
|
214
331
|
.pipeThrough(new DecompressionStream("gzip"));
|
|
215
|
-
return new Uint8Array(
|
|
332
|
+
return new Uint8Array(
|
|
333
|
+
await collectBoundedStream(stream, maxBytes, "expanded", signal, async () =>
|
|
334
|
+
new Response(stream).arrayBuffer(),
|
|
335
|
+
),
|
|
336
|
+
);
|
|
216
337
|
}
|
|
217
338
|
|
|
218
339
|
export interface IMountedFiles {
|
|
@@ -307,12 +428,18 @@ function readTarString(bytes: Uint8Array, decoder: TextDecoder): string {
|
|
|
307
428
|
|
|
308
429
|
function trimNull(text: string): string {
|
|
309
430
|
const index = text.indexOf("\0");
|
|
310
|
-
return
|
|
431
|
+
return index < 0 ? text : text.slice(0, index);
|
|
311
432
|
}
|
|
312
433
|
|
|
313
434
|
function parseOctal(bytes: Uint8Array): number {
|
|
314
435
|
const text = trimNull(new TextDecoder().decode(bytes)).trim();
|
|
315
|
-
|
|
436
|
+
if (text.length === 0) return 0;
|
|
437
|
+
if (!/^[0-7]+$/.test(text)) throw new Error("Invalid tar entry size.");
|
|
438
|
+
const value = Number.parseInt(text, 8);
|
|
439
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
440
|
+
throw new Error("Tar entry size is outside the safe integer range.");
|
|
441
|
+
}
|
|
442
|
+
return value;
|
|
316
443
|
}
|
|
317
444
|
|
|
318
445
|
function parsePaxPath(bytes: Uint8Array): string | null {
|
|
@@ -343,11 +470,284 @@ function parsePaxPath(bytes: Uint8Array): string | null {
|
|
|
343
470
|
return path;
|
|
344
471
|
}
|
|
345
472
|
|
|
346
|
-
function
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
473
|
+
function readTarHeaderPath(header: Uint8Array, decoder: TextDecoder): string {
|
|
474
|
+
const name = readTarString(header.subarray(0, 100), decoder);
|
|
475
|
+
const prefix = readTarString(header.subarray(345, 500), decoder);
|
|
476
|
+
return prefix ? `${prefix}/${name}` : name;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Require an npm archive path below one safe, consistent top-level root.
|
|
481
|
+
*
|
|
482
|
+
* Npm normally emits `package/`, while current DefinitelyTyped tarballs use
|
|
483
|
+
* roots such as `node/` and `react/`. The root spelling does not enter the
|
|
484
|
+
* mounted key; consistency and safe remaining segments provide confinement.
|
|
485
|
+
*/
|
|
486
|
+
function confineTarPath(
|
|
487
|
+
rawPath: string,
|
|
488
|
+
archiveRoot: string | null,
|
|
489
|
+
): { relative: string; root: string } {
|
|
490
|
+
if (
|
|
491
|
+
rawPath.length === 0 ||
|
|
492
|
+
rawPath.includes("\\") ||
|
|
493
|
+
rawPath.startsWith("/") ||
|
|
494
|
+
/^[a-zA-Z]:/.test(rawPath)
|
|
495
|
+
) {
|
|
496
|
+
throw new Error(`Invalid npm tar entry path ${JSON.stringify(rawPath)}.`);
|
|
497
|
+
}
|
|
498
|
+
const segments = rawPath.split("/");
|
|
499
|
+
if (
|
|
500
|
+
segments.length < 2 ||
|
|
501
|
+
segments.some(
|
|
502
|
+
(segment, index) =>
|
|
503
|
+
segment.length === 0 ||
|
|
504
|
+
segment === "." ||
|
|
505
|
+
segment === ".." ||
|
|
506
|
+
(index > 0 && /^[a-zA-Z]:/.test(segment)),
|
|
507
|
+
)
|
|
508
|
+
) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
`npm tar entry is outside a confined package root: ${JSON.stringify(rawPath)}.`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
const root = segments[0]!;
|
|
514
|
+
if (archiveRoot !== null && root !== archiveRoot) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`npm tar archive mixes package roots ${JSON.stringify(archiveRoot)} and ${JSON.stringify(root)}.`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
return { relative: segments.slice(1).join("/"), root };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
interface IIntegrityCandidate {
|
|
523
|
+
algorithm: string;
|
|
524
|
+
digest: Uint8Array;
|
|
525
|
+
rank: number;
|
|
526
|
+
webAlgorithm: AlgorithmIdentifier;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function parseIntegrity(integrity: string): IIntegrityCandidate[] {
|
|
530
|
+
const tokens = integrity.trim().split(/\s+/).filter(Boolean);
|
|
531
|
+
if (tokens.length === 0) throw new Error("tarball integrity is empty.");
|
|
532
|
+
const candidates: IIntegrityCandidate[] = [];
|
|
533
|
+
for (const token of tokens) {
|
|
534
|
+
const match = /^([A-Za-z0-9]+)-([A-Za-z0-9+/]+={0,2})(?:\?[!-~]+)?$/i.exec(
|
|
535
|
+
token,
|
|
536
|
+
);
|
|
537
|
+
if (!match) {
|
|
538
|
+
throw new Error("tarball integrity contains malformed metadata.");
|
|
539
|
+
}
|
|
540
|
+
const algorithm = match[1]!.toLowerCase();
|
|
541
|
+
if (!["sha1", "sha256", "sha384", "sha512"].includes(algorithm)) {
|
|
542
|
+
decodeBase64(match[2]!);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const expectedLength =
|
|
546
|
+
algorithm === "sha512"
|
|
547
|
+
? 64
|
|
548
|
+
: algorithm === "sha384"
|
|
549
|
+
? 48
|
|
550
|
+
: algorithm === "sha256"
|
|
551
|
+
? 32
|
|
552
|
+
: 20;
|
|
553
|
+
const digest = decodeBase64(match[2]!);
|
|
554
|
+
if (digest.length !== expectedLength) {
|
|
555
|
+
throw new Error("tarball integrity contains a malformed digest.");
|
|
556
|
+
}
|
|
557
|
+
candidates.push({
|
|
558
|
+
algorithm,
|
|
559
|
+
digest,
|
|
560
|
+
rank: expectedLength,
|
|
561
|
+
webAlgorithm: algorithm.replace("sha", "SHA-") as AlgorithmIdentifier,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
if (candidates.length === 0) {
|
|
565
|
+
throw new Error("tarball integrity has no supported digest algorithm.");
|
|
566
|
+
}
|
|
567
|
+
return candidates;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function decodeBase64(value: string): Uint8Array {
|
|
571
|
+
try {
|
|
572
|
+
const decoded = atob(value);
|
|
573
|
+
return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
|
574
|
+
} catch {
|
|
575
|
+
throw new Error("tarball integrity contains malformed base64.");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function decodeHex(value: string): Uint8Array {
|
|
580
|
+
return Uint8Array.from(value.match(/../g) ?? [], (byte) =>
|
|
581
|
+
Number.parseInt(byte, 16),
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
586
|
+
if (left.length !== right.length) return false;
|
|
587
|
+
let difference = 0;
|
|
588
|
+
for (let index = 0; index < left.length; ++index) {
|
|
589
|
+
difference |= left[index]! ^ right[index]!;
|
|
590
|
+
}
|
|
591
|
+
return difference === 0;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function collectBoundedStream(
|
|
595
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
596
|
+
maxBytes: ValidatedNpmByteLimit,
|
|
597
|
+
kind: "compressed" | "expanded",
|
|
598
|
+
signal: AbortSignal | undefined,
|
|
599
|
+
fallback: () => Promise<ArrayBuffer>,
|
|
600
|
+
): Promise<ArrayBuffer> {
|
|
601
|
+
if (stream === null) {
|
|
602
|
+
throwIfAborted(signal);
|
|
603
|
+
const bytes = await abortable(fallback, signal);
|
|
604
|
+
throwIfAborted(signal);
|
|
605
|
+
if (bytes.byteLength > maxBytes) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
`tarball exceeds the ${formatByteLimit(maxBytes)} ${kind} byte limit.`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
return bytes;
|
|
611
|
+
}
|
|
612
|
+
const reader = stream.getReader();
|
|
613
|
+
const chunks: Uint8Array[] = [];
|
|
614
|
+
let length = 0;
|
|
615
|
+
let abort: (() => void) | undefined;
|
|
616
|
+
const aborted =
|
|
617
|
+
signal === undefined
|
|
618
|
+
? undefined
|
|
619
|
+
: new Promise<never>((_resolve, reject) => {
|
|
620
|
+
abort = () => {
|
|
621
|
+
let error: unknown;
|
|
622
|
+
try {
|
|
623
|
+
throwIfAborted(signal);
|
|
624
|
+
return;
|
|
625
|
+
} catch (caught) {
|
|
626
|
+
error = caught;
|
|
627
|
+
}
|
|
628
|
+
void reader.cancel(error).catch(() => undefined);
|
|
629
|
+
reject(error);
|
|
630
|
+
};
|
|
631
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
632
|
+
});
|
|
633
|
+
try {
|
|
634
|
+
for (;;) {
|
|
635
|
+
throwIfAborted(signal);
|
|
636
|
+
const read = reader.read();
|
|
637
|
+
const next = aborted ? await Promise.race([read, aborted]) : await read;
|
|
638
|
+
throwIfAborted(signal);
|
|
639
|
+
if (next.done) break;
|
|
640
|
+
if (next.value.byteLength > maxBytes - length) {
|
|
641
|
+
void reader.cancel().catch(() => undefined);
|
|
642
|
+
throw new Error(
|
|
643
|
+
`tarball exceeds the ${formatByteLimit(maxBytes)} ${kind} byte limit.`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
chunks.push(next.value);
|
|
647
|
+
length += next.value.byteLength;
|
|
648
|
+
}
|
|
649
|
+
} catch (error) {
|
|
650
|
+
void reader.cancel(error).catch(() => undefined);
|
|
651
|
+
throw error;
|
|
652
|
+
} finally {
|
|
653
|
+
if (abort !== undefined) signal?.removeEventListener("abort", abort);
|
|
654
|
+
}
|
|
655
|
+
const output = new Uint8Array(length);
|
|
656
|
+
let offset = 0;
|
|
657
|
+
for (const chunk of chunks) {
|
|
658
|
+
output.set(chunk, offset);
|
|
659
|
+
offset += chunk.byteLength;
|
|
660
|
+
}
|
|
661
|
+
return output.buffer;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function formatByteLimit(bytes: number): string {
|
|
665
|
+
return `${bytes.toLocaleString("en-US")}-byte`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Validate one public npm archive byte budget before starting related work. */
|
|
669
|
+
export function validateNpmByteLimit(
|
|
670
|
+
maxBytes: number,
|
|
671
|
+
kind: "compressed" | "expanded",
|
|
672
|
+
): ValidatedNpmByteLimit {
|
|
673
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
674
|
+
throw new Error(`${kind} byte limit must be a positive safe integer.`);
|
|
675
|
+
}
|
|
676
|
+
return maxBytes as ValidatedNpmByteLimit;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Reject promptly on abort even when an underlying browser task is not
|
|
681
|
+
* cancellable.
|
|
682
|
+
*/
|
|
683
|
+
function abortable<T>(
|
|
684
|
+
start: () => Promise<T>,
|
|
685
|
+
signal: AbortSignal | undefined,
|
|
686
|
+
disposeLateValue?: (value: T) => void,
|
|
687
|
+
): Promise<T> {
|
|
688
|
+
throwIfAborted(signal);
|
|
689
|
+
let task: Promise<T>;
|
|
690
|
+
try {
|
|
691
|
+
task = start();
|
|
692
|
+
} catch (error) {
|
|
693
|
+
throwIfAborted(signal);
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
if (signal === undefined) return task;
|
|
697
|
+
return new Promise<T>((resolve, reject) => {
|
|
698
|
+
let aborted = false;
|
|
699
|
+
const abort = () => {
|
|
700
|
+
if (aborted) return;
|
|
701
|
+
aborted = true;
|
|
702
|
+
signal.removeEventListener("abort", abort);
|
|
703
|
+
try {
|
|
704
|
+
throwIfAborted(signal);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
reject(error);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
710
|
+
void task
|
|
711
|
+
.then((value) => {
|
|
712
|
+
if (signal.aborted) {
|
|
713
|
+
try {
|
|
714
|
+
disposeLateValue?.(value);
|
|
715
|
+
} catch {
|
|
716
|
+
// Disposal is best-effort and must not replace the abort reason.
|
|
717
|
+
}
|
|
718
|
+
abort();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
resolve(value);
|
|
722
|
+
}, reject)
|
|
723
|
+
.finally(() => {
|
|
724
|
+
signal.removeEventListener("abort", abort);
|
|
725
|
+
});
|
|
726
|
+
if (signal.aborted) abort();
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Cancel an unused response body without obscuring the deciding outcome. */
|
|
731
|
+
function cancelResponseBody(response: Response): void {
|
|
732
|
+
void response.body?.cancel().catch(() => undefined);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Preserve the abort reason across the response-to-caller handoff. */
|
|
736
|
+
function throwIfResponseAborted(
|
|
737
|
+
response: Response,
|
|
738
|
+
signal: AbortSignal | undefined,
|
|
739
|
+
): void {
|
|
740
|
+
if (!signal?.aborted) return;
|
|
741
|
+
cancelResponseBody(response);
|
|
742
|
+
throwIfAborted(signal);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Fetch one response and cancel any body that loses the abort race. */
|
|
746
|
+
async function fetchWithAbort(
|
|
747
|
+
start: () => Promise<Response>,
|
|
748
|
+
signal: AbortSignal | undefined,
|
|
749
|
+
): Promise<Response> {
|
|
750
|
+
const response = await abortable(start, signal, cancelResponseBody);
|
|
751
|
+
throwIfResponseAborted(response, signal);
|
|
752
|
+
return response;
|
|
353
753
|
}
|