@hyperframes/engine 0.7.92 → 0.7.93
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/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/services/audioMixer.d.ts.map +1 -1
- package/dist/services/audioMixer.js +15 -6
- package/dist/services/audioMixer.js.map +1 -1
- package/dist/services/browserManager.d.ts +18 -7
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js +82 -21
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/frameCapture.d.ts +25 -0
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +54 -9
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +5 -2
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/utils/urlDownloader.d.ts +46 -3
- package/dist/utils/urlDownloader.d.ts.map +1 -1
- package/dist/utils/urlDownloader.js +723 -55
- package/dist/utils/urlDownloader.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { closeSync, createWriteStream, existsSync, fsyncSync, mkdtempSync, mkdirSync, lstatSync, openSync,
|
|
2
|
-
import { createHash } from "crypto";
|
|
1
|
+
import { closeSync, createReadStream, createWriteStream, existsSync, fsyncSync, linkSync, mkdtempSync, mkdirSync, lstatSync, openSync, readdirSync, rmdirSync, rmSync, statSync, unlinkSync, } from "fs";
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
3
|
import { BlockList, isIP } from "node:net";
|
|
4
4
|
import { dirname, extname, join } from "path";
|
|
5
|
-
import { Readable } from "stream";
|
|
5
|
+
import { Readable, Transform } from "stream";
|
|
6
6
|
import { pipeline } from "stream/promises";
|
|
7
7
|
const inFlightDownloads = new Map();
|
|
8
8
|
const signalScopes = new WeakMap();
|
|
@@ -18,20 +18,54 @@ function signalScopeKey(signal) {
|
|
|
18
18
|
}
|
|
19
19
|
return String(scope);
|
|
20
20
|
}
|
|
21
|
+
/** Query-free, non-reversible URL identity suitable for logs and metrics. */
|
|
22
|
+
export function safeDownloadUrlIdentity(url) {
|
|
23
|
+
let canonical = url;
|
|
24
|
+
let host;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = new URL(url);
|
|
27
|
+
canonical = `${parsed.origin}${parsed.pathname}`;
|
|
28
|
+
host = parsed.hostname.toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Invalid input is still fingerprinted; never echo it in diagnostics.
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
urlFingerprint: createHash("sha256").update(canonical).digest("hex"),
|
|
35
|
+
host,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Default safe structured sink for engine media call sites without a logger. */
|
|
39
|
+
export function writeUrlDownloadTelemetry(event) {
|
|
40
|
+
try {
|
|
41
|
+
process.stderr.write(`[hyperframes:download] ${JSON.stringify(event)}\n`);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Observability must never change download correctness.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
21
47
|
export class UrlDownloadError extends Error {
|
|
22
48
|
kind;
|
|
23
49
|
retryable;
|
|
24
50
|
status;
|
|
25
|
-
|
|
51
|
+
telemetry;
|
|
52
|
+
locallyRetryable;
|
|
53
|
+
constructor(kind, retryable, message, status, telemetry,
|
|
54
|
+
/** A bounded in-call refetch can be safe even when upstream retry is not. */
|
|
55
|
+
locallyRetryable = retryable) {
|
|
26
56
|
super(message);
|
|
27
57
|
this.kind = kind;
|
|
28
58
|
this.retryable = retryable;
|
|
29
59
|
this.status = status;
|
|
60
|
+
this.telemetry = telemetry;
|
|
61
|
+
this.locallyRetryable = locallyRetryable;
|
|
30
62
|
this.name = "UrlDownloadError";
|
|
31
63
|
}
|
|
32
64
|
}
|
|
33
|
-
function classifyHttpFailure(status
|
|
34
|
-
|
|
65
|
+
function classifyHttpFailure(status) {
|
|
66
|
+
// Response.statusText is remote-controlled and some CDNs/proxies echo the
|
|
67
|
+
// signed request URL into it. The numeric status is sufficient and bounded.
|
|
68
|
+
const message = `HTTP ${status}`;
|
|
35
69
|
if (status === 404 || status === 410) {
|
|
36
70
|
return new UrlDownloadError("http_not_found", false, message, status);
|
|
37
71
|
}
|
|
@@ -43,20 +77,19 @@ function classifyHttpFailure(status, statusText) {
|
|
|
43
77
|
function classifyDownloadFailure(error) {
|
|
44
78
|
if (error instanceof UrlDownloadError)
|
|
45
79
|
return error;
|
|
46
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
47
80
|
let current = error;
|
|
48
81
|
// Undici often wraps a mid-body socket failure as `TypeError: terminated`
|
|
49
82
|
// with the actionable `UND_ERR_*` code on `cause`.
|
|
50
83
|
for (let depth = 0; current && depth < 4; depth += 1) {
|
|
51
84
|
if (isRetryableNetworkCause(current)) {
|
|
52
|
-
return new UrlDownloadError("network", true,
|
|
85
|
+
return new UrlDownloadError("network", true, "Download failed due to a transient network error");
|
|
53
86
|
}
|
|
54
87
|
current =
|
|
55
88
|
typeof current === "object" && current !== null && "cause" in current
|
|
56
89
|
? current.cause
|
|
57
90
|
: undefined;
|
|
58
91
|
}
|
|
59
|
-
return new UrlDownloadError("filesystem", false,
|
|
92
|
+
return new UrlDownloadError("filesystem", false, "Download failed while writing the local artifact");
|
|
60
93
|
}
|
|
61
94
|
const RETRYABLE_NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"]);
|
|
62
95
|
function isRetryableNetworkCause(error) {
|
|
@@ -123,31 +156,192 @@ export function assertPublicHttpsUrl(url) {
|
|
|
123
156
|
parsed = new URL(url);
|
|
124
157
|
}
|
|
125
158
|
catch {
|
|
126
|
-
throw new Error(
|
|
159
|
+
throw new Error("[URLDownloader] Invalid URL");
|
|
127
160
|
}
|
|
128
161
|
if (parsed.protocol !== "https:") {
|
|
129
|
-
throw new Error(`[URLDownloader] Only HTTPS URLs are permitted in compositions
|
|
162
|
+
throw new Error(`[URLDownloader] Only HTTPS URLs are permitted in compositions`);
|
|
130
163
|
}
|
|
131
164
|
if (isBlockedHost(parsed.hostname)) {
|
|
132
|
-
throw new Error(
|
|
165
|
+
throw new Error("[URLDownloader] URL targets a private/reserved address and is not permitted");
|
|
133
166
|
}
|
|
134
167
|
}
|
|
135
|
-
function getFilenameFromUrl(url) {
|
|
136
|
-
const
|
|
168
|
+
function getFilenameFromUrl(url, validationScope) {
|
|
169
|
+
const physicalIdentity = validationScope === "" ? url : `${url}\0${validationScope}`;
|
|
170
|
+
const hash = createHash("md5").update(physicalIdentity).digest("hex").slice(0, 12);
|
|
137
171
|
const urlObj = new URL(url);
|
|
138
172
|
const ext = extname(urlObj.pathname) || ".mp4";
|
|
139
173
|
return `download_${hash}${ext}`;
|
|
140
174
|
}
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
175
|
+
function sameFileIdentity(left, right) {
|
|
176
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
177
|
+
}
|
|
178
|
+
const CACHE_LOCK_POLL_MS = 10;
|
|
179
|
+
const CACHE_LOCK_STALE_MS = 5 * 60_000;
|
|
180
|
+
const CACHE_LOCK_RECLAIM_NAME = ".hf-reclaim";
|
|
181
|
+
const CACHE_LOCK_OWNER_PREFIX = ".hf-owner-";
|
|
182
|
+
function sameCacheLockStatGeneration(left, right) {
|
|
183
|
+
return (sameFileIdentity(left, right) &&
|
|
184
|
+
left.mtimeMs === right.mtimeMs &&
|
|
185
|
+
left.ctimeMs === right.ctimeMs &&
|
|
186
|
+
left.birthtimeMs === right.birthtimeMs);
|
|
187
|
+
}
|
|
188
|
+
function observeCachePathLock(lockPath) {
|
|
189
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
190
|
+
const before = lstatSync(lockPath);
|
|
191
|
+
const owner = readdirSync(lockPath).find((name) => name.startsWith(CACHE_LOCK_OWNER_PREFIX));
|
|
192
|
+
const after = lstatSync(lockPath);
|
|
193
|
+
if (sameCacheLockStatGeneration(before, after))
|
|
194
|
+
return { stats: after, owner };
|
|
195
|
+
}
|
|
196
|
+
throw new UrlDownloadError("filesystem", true, "Cache lock changed repeatedly during inspection");
|
|
197
|
+
}
|
|
198
|
+
function sameCachePathLock(left, right) {
|
|
199
|
+
if (left.owner !== undefined || right.owner !== undefined) {
|
|
200
|
+
return left.owner !== undefined && left.owner === right.owner;
|
|
201
|
+
}
|
|
202
|
+
// Backward-compatible fallback for lock directories created by an older
|
|
203
|
+
// process before ownership markers were introduced.
|
|
204
|
+
return sameFileIdentity(left.stats, right.stats);
|
|
205
|
+
}
|
|
206
|
+
function removeCacheLockDirectoryIfEmpty(lockPath) {
|
|
207
|
+
try {
|
|
208
|
+
rmdirSync(lockPath);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
const code = error.code;
|
|
212
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function releaseOwnedCachePathLock(lockPath, owner) {
|
|
217
|
+
try {
|
|
218
|
+
// Consuming the unique owner marker elects exactly one releaser. The
|
|
219
|
+
// non-recursive rmdir below cannot delete a successor with its own marker.
|
|
220
|
+
rmdirSync(join(lockPath, owner));
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
if (error.code === "ENOENT")
|
|
224
|
+
return;
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
removeCacheLockDirectoryIfEmpty(lockPath);
|
|
228
|
+
}
|
|
229
|
+
async function waitForCacheLock(signal) {
|
|
230
|
+
if (signal?.aborted) {
|
|
231
|
+
throw new UrlDownloadError("cancelled", false, "Download cancelled");
|
|
232
|
+
}
|
|
233
|
+
await new Promise((resolve, reject) => {
|
|
234
|
+
const timeout = setTimeout(() => {
|
|
235
|
+
signal?.removeEventListener("abort", onAbort);
|
|
236
|
+
resolve();
|
|
237
|
+
}, CACHE_LOCK_POLL_MS);
|
|
238
|
+
const onAbort = () => {
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
signal?.removeEventListener("abort", onAbort);
|
|
241
|
+
reject(new UrlDownloadError("cancelled", false, "Download cancelled"));
|
|
242
|
+
};
|
|
243
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
// The lock loop keeps filesystem races, stale-lock recovery, cancellation, and timeout together.
|
|
247
|
+
// fallow-ignore-next-line complexity
|
|
248
|
+
async function acquireCachePathLock(localPath, timeoutMs, signal) {
|
|
249
|
+
const lockPath = `${localPath}.hf-lock`;
|
|
250
|
+
const startedAt = Date.now();
|
|
251
|
+
for (;;) {
|
|
252
|
+
if (signal?.aborted) {
|
|
253
|
+
throw new UrlDownloadError("cancelled", false, "Download cancelled");
|
|
254
|
+
}
|
|
255
|
+
let createdLock = false;
|
|
256
|
+
try {
|
|
257
|
+
mkdirSync(lockPath);
|
|
258
|
+
createdLock = true;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (error.code !== "EEXIST")
|
|
262
|
+
throw error;
|
|
263
|
+
// Another process owns the path; inspect it below.
|
|
264
|
+
}
|
|
265
|
+
if (createdLock) {
|
|
266
|
+
const owner = `${CACHE_LOCK_OWNER_PREFIX}${randomUUID()}`;
|
|
267
|
+
try {
|
|
268
|
+
mkdirSync(join(lockPath, owner));
|
|
269
|
+
const entries = readdirSync(lockPath);
|
|
270
|
+
if (entries.length === 1 && entries[0] === owner) {
|
|
271
|
+
return () => releaseOwnedCachePathLock(lockPath, owner);
|
|
272
|
+
}
|
|
273
|
+
// The empty directory was replaced or another creator reached it
|
|
274
|
+
// before our marker. Consume only our marker and retry ownership.
|
|
275
|
+
rmdirSync(join(lockPath, owner));
|
|
276
|
+
removeCacheLockDirectoryIfEmpty(lockPath);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (error.code === "ENOENT")
|
|
281
|
+
continue;
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
let observedLock;
|
|
286
|
+
try {
|
|
287
|
+
observedLock = observeCachePathLock(lockPath);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error.code === "ENOENT")
|
|
291
|
+
continue;
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
if (Date.now() - observedLock.stats.mtimeMs > CACHE_LOCK_STALE_MS) {
|
|
295
|
+
if (observedLock.owner) {
|
|
296
|
+
// Removing the exact unique marker is an atomic ownership claim.
|
|
297
|
+
// A competing releaser/reclaimer gets ENOENT and must re-observe.
|
|
298
|
+
try {
|
|
299
|
+
rmdirSync(join(lockPath, observedLock.owner));
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (error.code === "ENOENT")
|
|
303
|
+
continue;
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
removeCacheLockDirectoryIfEmpty(lockPath);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// Compatibility path for stale marker-less locks from an older process.
|
|
310
|
+
const reclaimPath = join(lockPath, CACHE_LOCK_RECLAIM_NAME);
|
|
311
|
+
try {
|
|
312
|
+
// A marker inside the observed lock serializes competing stale
|
|
313
|
+
// reclaimers without introducing another independently stale lock.
|
|
314
|
+
mkdirSync(reclaimPath);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
const code = error.code;
|
|
318
|
+
if (code === "EEXIST" || code === "ENOENT")
|
|
319
|
+
continue;
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const currentLock = observeCachePathLock(lockPath);
|
|
324
|
+
if (sameCachePathLock(currentLock, observedLock)) {
|
|
325
|
+
rmdirSync(reclaimPath);
|
|
326
|
+
removeCacheLockDirectoryIfEmpty(lockPath);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
// The stale lock was replaced after our observation. Remove only
|
|
330
|
+
// our marker from the successor and leave its ownership intact.
|
|
331
|
+
rmdirSync(reclaimPath);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (error.code !== "ENOENT")
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
341
|
+
throw new UrlDownloadError("timeout", true, `Download cache lock timeout after ${timeoutMs / 1000}s`);
|
|
342
|
+
}
|
|
343
|
+
await waitForCacheLock(signal);
|
|
344
|
+
}
|
|
151
345
|
}
|
|
152
346
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
153
347
|
const MAX_REDIRECTS = 5;
|
|
@@ -192,15 +386,205 @@ async function fetchWithValidatedRedirects(initialUrl, controller) {
|
|
|
192
386
|
const response = await fetch(currentUrl, {
|
|
193
387
|
signal: controller.signal,
|
|
194
388
|
redirect: "manual",
|
|
389
|
+
headers: { "accept-encoding": "identity" },
|
|
195
390
|
});
|
|
196
391
|
if (!REDIRECT_STATUSES.has(response.status))
|
|
197
|
-
return response;
|
|
392
|
+
return { response, finalUrl: currentUrl };
|
|
198
393
|
await cancelResponseBody(response);
|
|
199
394
|
currentUrl = resolveRedirectUrl(response, currentUrl, redirects);
|
|
200
395
|
}
|
|
201
396
|
}
|
|
202
|
-
|
|
203
|
-
|
|
397
|
+
/** Fetch bounded UTF-8 text while applying the downloader's redirect and SSRF policy to every hop. */
|
|
398
|
+
// fallow-ignore-next-line complexity
|
|
399
|
+
export async function fetchPublicHttpsText(url, options) {
|
|
400
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
401
|
+
if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0) {
|
|
402
|
+
throw new RangeError("maxBytes must be a positive safe integer");
|
|
403
|
+
}
|
|
404
|
+
assertPublicHttpsUrl(url);
|
|
405
|
+
const controller = new AbortController();
|
|
406
|
+
let timedOut = false;
|
|
407
|
+
let callerAborted = options.signal?.aborted ?? false;
|
|
408
|
+
const onCallerAbort = () => {
|
|
409
|
+
callerAborted = true;
|
|
410
|
+
controller.abort();
|
|
411
|
+
};
|
|
412
|
+
options.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
413
|
+
const timeoutId = setTimeout(() => {
|
|
414
|
+
timedOut = true;
|
|
415
|
+
controller.abort();
|
|
416
|
+
}, timeoutMs);
|
|
417
|
+
try {
|
|
418
|
+
if (callerAborted) {
|
|
419
|
+
throw new UrlDownloadError("cancelled", false, "Text fetch cancelled");
|
|
420
|
+
}
|
|
421
|
+
const { response } = await fetchWithValidatedRedirects(url, controller);
|
|
422
|
+
if (!response.ok) {
|
|
423
|
+
await cancelResponseBody(response);
|
|
424
|
+
throw classifyHttpFailure(response.status);
|
|
425
|
+
}
|
|
426
|
+
if (!response.body)
|
|
427
|
+
return "";
|
|
428
|
+
let declaredLength;
|
|
429
|
+
try {
|
|
430
|
+
declaredLength = parseDeclaredLength(response);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
await cancelResponseBody(response);
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
const contentEncoding = response.headers.get("content-encoding")?.trim().toLowerCase();
|
|
437
|
+
const expectedBytes = !contentEncoding || contentEncoding === "identity" ? declaredLength : undefined;
|
|
438
|
+
if (declaredLength !== undefined && declaredLength > options.maxBytes) {
|
|
439
|
+
await cancelResponseBody(response);
|
|
440
|
+
throw new UrlDownloadError("length_mismatch", false, "Text response exceeded the configured byte limit", response.status);
|
|
441
|
+
}
|
|
442
|
+
const reader = response.body.getReader();
|
|
443
|
+
const chunks = [];
|
|
444
|
+
let receivedBytes = 0;
|
|
445
|
+
for (;;) {
|
|
446
|
+
const { done, value } = await reader.read();
|
|
447
|
+
if (done)
|
|
448
|
+
break;
|
|
449
|
+
receivedBytes += value.byteLength;
|
|
450
|
+
if (receivedBytes > options.maxBytes) {
|
|
451
|
+
await reader.cancel();
|
|
452
|
+
throw new UrlDownloadError("length_mismatch", false, "Text response exceeded the configured byte limit", response.status, { receivedBytes });
|
|
453
|
+
}
|
|
454
|
+
chunks.push(value);
|
|
455
|
+
}
|
|
456
|
+
if (expectedBytes !== undefined && receivedBytes !== expectedBytes) {
|
|
457
|
+
throw new UrlDownloadError("length_mismatch", true, "Text response byte count did not match its declared length", response.status, { expectedBytes, receivedBytes });
|
|
458
|
+
}
|
|
459
|
+
return new TextDecoder().decode(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))));
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
if (callerAborted) {
|
|
463
|
+
throw new UrlDownloadError("cancelled", false, "Text fetch cancelled");
|
|
464
|
+
}
|
|
465
|
+
if (timedOut) {
|
|
466
|
+
throw new UrlDownloadError("timeout", true, `Text fetch timeout after ${timeoutMs / 1000}s`);
|
|
467
|
+
}
|
|
468
|
+
throw classifyDownloadFailure(error);
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
clearTimeout(timeoutId);
|
|
472
|
+
options.signal?.removeEventListener("abort", onCallerAbort);
|
|
473
|
+
controller.abort();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function parseDeclaredLength(response) {
|
|
477
|
+
const raw = response.headers.get("content-length");
|
|
478
|
+
if (raw === null)
|
|
479
|
+
return undefined;
|
|
480
|
+
if (!/^\d+$/.test(raw.trim())) {
|
|
481
|
+
throw new UrlDownloadError("length_mismatch", true, "Download response Content-Length is malformed", response.status, { status: response.status });
|
|
482
|
+
}
|
|
483
|
+
const value = Number(raw);
|
|
484
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
485
|
+
throw new UrlDownloadError("length_mismatch", true, "Download response Content-Length is out of range", response.status, { status: response.status });
|
|
486
|
+
}
|
|
487
|
+
return value;
|
|
488
|
+
}
|
|
489
|
+
// Keep every Content-Range invariant in one parser so malformed and unsolicited
|
|
490
|
+
// partial responses cannot drift into different retry classifications.
|
|
491
|
+
// fallow-ignore-next-line complexity
|
|
492
|
+
function classifyRangeDisposition(response) {
|
|
493
|
+
const contentRange = response.headers.get("content-range");
|
|
494
|
+
if (response.status === 206) {
|
|
495
|
+
const match = contentRange?.match(/^bytes (\d+)-(\d+)\/(\d+|\*)$/i);
|
|
496
|
+
if (!match)
|
|
497
|
+
return "malformed_206";
|
|
498
|
+
const start = Number(match[1]);
|
|
499
|
+
const end = Number(match[2]);
|
|
500
|
+
const total = match[3] === "*" ? undefined : Number(match[3]);
|
|
501
|
+
if (!Number.isSafeInteger(start) ||
|
|
502
|
+
!Number.isSafeInteger(end) ||
|
|
503
|
+
start < 0 ||
|
|
504
|
+
end < start ||
|
|
505
|
+
(total !== undefined && (!Number.isSafeInteger(total) || total <= end))) {
|
|
506
|
+
return "malformed_206";
|
|
507
|
+
}
|
|
508
|
+
return "unsolicited_206";
|
|
509
|
+
}
|
|
510
|
+
if (response.status !== 200 || contentRange === null)
|
|
511
|
+
return "none";
|
|
512
|
+
const match = contentRange.match(/^bytes (\d+)-(\d+)\/(\d+)$/i);
|
|
513
|
+
const contentLength = response.headers.get("content-length")?.trim();
|
|
514
|
+
const contentEncoding = response.headers.get("content-encoding")?.trim().toLowerCase();
|
|
515
|
+
if (!match || !contentLength || !/^\d+$/.test(contentLength)) {
|
|
516
|
+
return "content_range_on_200";
|
|
517
|
+
}
|
|
518
|
+
const start = Number(match[1]);
|
|
519
|
+
const end = Number(match[2]);
|
|
520
|
+
const total = Number(match[3]);
|
|
521
|
+
const declaredLength = Number(contentLength);
|
|
522
|
+
return Number.isSafeInteger(start) &&
|
|
523
|
+
Number.isSafeInteger(end) &&
|
|
524
|
+
Number.isSafeInteger(total) &&
|
|
525
|
+
Number.isSafeInteger(declaredLength) &&
|
|
526
|
+
start === 0 &&
|
|
527
|
+
total > 0 &&
|
|
528
|
+
end === total - 1 &&
|
|
529
|
+
declaredLength === total &&
|
|
530
|
+
(!contentEncoding || contentEncoding === "identity")
|
|
531
|
+
? "full_object_200"
|
|
532
|
+
: "content_range_on_200";
|
|
533
|
+
}
|
|
534
|
+
function looksLikeRemoteErrorDocument(prefix) {
|
|
535
|
+
const text = prefix
|
|
536
|
+
.toString("utf8")
|
|
537
|
+
.replace(/^\uFEFF?\s*/, "")
|
|
538
|
+
.toLowerCase();
|
|
539
|
+
return (text.startsWith("<!doctype html") ||
|
|
540
|
+
text.startsWith("<html") ||
|
|
541
|
+
text.startsWith("<head") ||
|
|
542
|
+
text.startsWith("<body") ||
|
|
543
|
+
text.startsWith("<error") ||
|
|
544
|
+
/^\{\s*"(?:error|message|detail|code|status)"\s*:/i.test(text) ||
|
|
545
|
+
(/^<\?xml\b/.test(text) && /<(?:html|error)\b/.test(text)));
|
|
546
|
+
}
|
|
547
|
+
function normalizeCallerSha256(value) {
|
|
548
|
+
if (value === undefined)
|
|
549
|
+
return undefined;
|
|
550
|
+
const normalized = value.trim().toLowerCase();
|
|
551
|
+
if (!/^[a-f0-9]{64}$/.test(normalized)) {
|
|
552
|
+
throw new UrlDownloadError("hash_mismatch", false, "Caller-provided SHA-256 checksum is malformed");
|
|
553
|
+
}
|
|
554
|
+
return normalized;
|
|
555
|
+
}
|
|
556
|
+
function expectedResponseSha256(response, callerSha256) {
|
|
557
|
+
if (callerSha256)
|
|
558
|
+
return { value: callerSha256, encoding: "hex", source: "caller" };
|
|
559
|
+
const amazonChecksum = response.headers.get("x-amz-checksum-sha256")?.trim();
|
|
560
|
+
const amazonChecksumType = response.headers.get("x-amz-checksum-type")?.trim().toUpperCase();
|
|
561
|
+
if (amazonChecksum && amazonChecksumType !== "COMPOSITE") {
|
|
562
|
+
return { value: amazonChecksum, encoding: "base64", source: "server" };
|
|
563
|
+
}
|
|
564
|
+
const digest = response.headers.get("digest");
|
|
565
|
+
const sha256Digest = digest?.match(/(?:^|,)\s*sha-256=:?([^,:\s]+):?/i)?.[1];
|
|
566
|
+
return sha256Digest ? { value: sha256Digest, encoding: "base64", source: "server" } : null;
|
|
567
|
+
}
|
|
568
|
+
function checksumMismatchError(source, status, telemetry) {
|
|
569
|
+
return new UrlDownloadError("hash_mismatch", source === "server", "Download payload checksum did not match", status, telemetry, true);
|
|
570
|
+
}
|
|
571
|
+
// Response protocol, streamed byte accounting, hashes, and payload validation
|
|
572
|
+
// share one lifecycle so no validation can happen after publication.
|
|
573
|
+
// fallow-ignore-next-line complexity
|
|
574
|
+
async function fetchToPartial(url, partialPath, controller, options) {
|
|
575
|
+
const { response, finalUrl } = await fetchWithValidatedRedirects(url, controller);
|
|
576
|
+
const finalIdentity = safeDownloadUrlIdentity(finalUrl);
|
|
577
|
+
const rangeDisposition = classifyRangeDisposition(response);
|
|
578
|
+
if (rangeDisposition !== "none" && rangeDisposition !== "full_object_200") {
|
|
579
|
+
await cancelResponseBody(response);
|
|
580
|
+
throw new UrlDownloadError("range_protocol", true, rangeDisposition === "malformed_206"
|
|
581
|
+
? "Download received a malformed unsolicited partial response"
|
|
582
|
+
: "Download received an unsolicited partial response", response.status, {
|
|
583
|
+
finalHost: finalIdentity.host,
|
|
584
|
+
status: response.status,
|
|
585
|
+
rangeDisposition,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
204
588
|
if (!response.ok) {
|
|
205
589
|
// Do not leave a streaming error response holding an Undici connection
|
|
206
590
|
// while the bounded retry starts.
|
|
@@ -210,18 +594,104 @@ async function fetchToPartial(url, partialPath, controller) {
|
|
|
210
594
|
catch {
|
|
211
595
|
// The HTTP status remains the useful failure if teardown also fails.
|
|
212
596
|
}
|
|
213
|
-
|
|
597
|
+
const classified = classifyHttpFailure(response.status);
|
|
598
|
+
throw new UrlDownloadError(classified.kind, classified.retryable, classified.message, classified.status, { finalHost: finalIdentity.host, status: response.status, rangeDisposition });
|
|
214
599
|
}
|
|
215
600
|
if (!response.body) {
|
|
216
|
-
throw new UrlDownloadError("empty_body", true, "Download response body is empty"
|
|
601
|
+
throw new UrlDownloadError("empty_body", true, "Download response body is empty", response.status, {
|
|
602
|
+
finalHost: finalIdentity.host,
|
|
603
|
+
status: response.status,
|
|
604
|
+
});
|
|
217
605
|
}
|
|
218
|
-
|
|
606
|
+
let declaredLength;
|
|
607
|
+
try {
|
|
608
|
+
declaredLength = parseDeclaredLength(response);
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
await cancelResponseBody(response);
|
|
612
|
+
if (error instanceof UrlDownloadError) {
|
|
613
|
+
throw new UrlDownloadError(error.kind, error.retryable, error.message, error.status, {
|
|
614
|
+
...error.telemetry,
|
|
615
|
+
finalHost: finalIdentity.host,
|
|
616
|
+
rangeDisposition,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
const contentEncoding = response.headers.get("content-encoding")?.trim().toLowerCase();
|
|
622
|
+
const expectedBytes = !contentEncoding || contentEncoding === "identity" ? declaredLength : undefined;
|
|
623
|
+
let receivedBytes = 0;
|
|
624
|
+
const sha256 = createHash("sha256");
|
|
625
|
+
const md5 = createHash("md5");
|
|
626
|
+
const prefixChunks = [];
|
|
627
|
+
let prefixBytes = 0;
|
|
628
|
+
const inspector = new Transform({
|
|
629
|
+
transform(chunk, _encoding, callback) {
|
|
630
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
631
|
+
receivedBytes += bytes.length;
|
|
632
|
+
sha256.update(bytes);
|
|
633
|
+
md5.update(bytes);
|
|
634
|
+
if (prefixBytes < 1024) {
|
|
635
|
+
const remaining = 1024 - prefixBytes;
|
|
636
|
+
const sample = bytes.subarray(0, remaining);
|
|
637
|
+
prefixChunks.push(sample);
|
|
638
|
+
prefixBytes += sample.length;
|
|
639
|
+
}
|
|
640
|
+
callback(null, bytes);
|
|
641
|
+
},
|
|
642
|
+
});
|
|
219
643
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
220
644
|
const readableStream = Readable.fromWeb(response.body);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
645
|
+
const fileStream = createWriteStream(partialPath, { flags: "wx" });
|
|
646
|
+
await pipeline(readableStream, inspector, fileStream);
|
|
647
|
+
const localSize = statSync(partialPath).size;
|
|
648
|
+
const sha256Bytes = sha256.digest();
|
|
649
|
+
const localSha256 = sha256Bytes.toString("hex");
|
|
650
|
+
const md5Base64 = md5.digest("base64");
|
|
651
|
+
const telemetry = {
|
|
652
|
+
finalHost: finalIdentity.host,
|
|
653
|
+
status: response.status,
|
|
654
|
+
expectedBytes,
|
|
655
|
+
receivedBytes,
|
|
656
|
+
rangeDisposition,
|
|
657
|
+
localSize,
|
|
658
|
+
localSha256,
|
|
659
|
+
};
|
|
660
|
+
if (receivedBytes === 0 || localSize === 0) {
|
|
661
|
+
throw new UrlDownloadError("empty_body", true, "Download response body contained zero bytes", response.status, telemetry);
|
|
662
|
+
}
|
|
663
|
+
if (localSize !== receivedBytes ||
|
|
664
|
+
(expectedBytes !== undefined && receivedBytes !== expectedBytes)) {
|
|
665
|
+
throw new UrlDownloadError("length_mismatch", true, "Download response byte count did not match its declared length", response.status, telemetry);
|
|
666
|
+
}
|
|
667
|
+
const prefix = Buffer.concat(prefixChunks);
|
|
668
|
+
if (looksLikeRemoteErrorDocument(prefix)) {
|
|
669
|
+
throw new UrlDownloadError("invalid_payload", false, "Download returned an HTML or JSON error document", response.status, telemetry);
|
|
224
670
|
}
|
|
671
|
+
const expectedSha256 = expectedResponseSha256(response, options.expectedSha256);
|
|
672
|
+
const checksumMatches = expectedSha256 === null ||
|
|
673
|
+
(expectedSha256.encoding === "base64"
|
|
674
|
+
? sha256Bytes.toString("base64") === expectedSha256.value
|
|
675
|
+
: localSha256 === expectedSha256.value);
|
|
676
|
+
const contentMd5 = response.headers.get("content-md5")?.trim();
|
|
677
|
+
if (!checksumMatches) {
|
|
678
|
+
throw checksumMismatchError(expectedSha256?.source ?? "server", response.status, telemetry);
|
|
679
|
+
}
|
|
680
|
+
if (expectedSha256 === null && contentMd5 && md5Base64 !== contentMd5) {
|
|
681
|
+
throw checksumMismatchError("server", response.status, telemetry);
|
|
682
|
+
}
|
|
683
|
+
const etag = response.headers.get("etag")?.trim();
|
|
684
|
+
return {
|
|
685
|
+
finalHost: finalIdentity.host,
|
|
686
|
+
status: response.status,
|
|
687
|
+
expectedBytes,
|
|
688
|
+
receivedBytes,
|
|
689
|
+
rangeDisposition,
|
|
690
|
+
etagFingerprint: etag ? createHash("sha256").update(etag).digest("hex") : undefined,
|
|
691
|
+
etagWeak: etag ? /^W\//i.test(etag) : undefined,
|
|
692
|
+
localSize,
|
|
693
|
+
localSha256,
|
|
694
|
+
};
|
|
225
695
|
}
|
|
226
696
|
function syncAndPublishPartial(partialPath, localPath) {
|
|
227
697
|
// Windows rejects fsync on a read-only handle (EPERM); the partial is ours
|
|
@@ -233,19 +703,43 @@ function syncAndPublishPartial(partialPath, localPath) {
|
|
|
233
703
|
finally {
|
|
234
704
|
closeSync(fd);
|
|
235
705
|
}
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
if (hasCompleteFile(localPath))
|
|
239
|
-
return;
|
|
706
|
+
// A hard-link publish is atomic and no-clobber: unlike rename(), it cannot
|
|
707
|
+
// replace a path that another successful caller has already returned.
|
|
240
708
|
try {
|
|
241
|
-
|
|
709
|
+
linkSync(partialPath, localPath);
|
|
710
|
+
unlinkSync(partialPath);
|
|
711
|
+
return "published";
|
|
242
712
|
}
|
|
243
713
|
catch (error) {
|
|
244
|
-
|
|
714
|
+
const code = error.code;
|
|
715
|
+
if (code !== "EEXIST")
|
|
245
716
|
throw error;
|
|
717
|
+
let winner;
|
|
718
|
+
try {
|
|
719
|
+
winner = lstatSync(localPath);
|
|
720
|
+
}
|
|
721
|
+
catch (inspectionError) {
|
|
722
|
+
if (inspectionError.code !== "ENOENT")
|
|
723
|
+
throw inspectionError;
|
|
724
|
+
throw new UrlDownloadError("filesystem", true, "Concurrent cache artifact disappeared before validation");
|
|
725
|
+
}
|
|
726
|
+
if (!winner.isFile() || winner.size === 0)
|
|
727
|
+
throw error;
|
|
728
|
+
return "race_reused";
|
|
246
729
|
}
|
|
247
730
|
}
|
|
248
|
-
|
|
731
|
+
function emitDownloadTelemetry(options, event) {
|
|
732
|
+
try {
|
|
733
|
+
options.onTelemetry?.(event);
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// Metrics/logging callbacks cannot affect publication or retry policy.
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Attempt-scoped cancellation, cleanup, publication, and telemetry deliberately
|
|
740
|
+
// remain under one try/finally so every exit removes the unique partial directory.
|
|
741
|
+
// fallow-ignore-next-line complexity
|
|
742
|
+
async function runDownloadAttempt(url, localPath, timeoutMs, attempt, options, signal) {
|
|
249
743
|
// A private, unguessable directory prevents symlink planting and keeps the
|
|
250
744
|
// partial on the destination filesystem so the final rename stays atomic.
|
|
251
745
|
const attemptDir = mkdtempSync(join(dirname(localPath), ".hf-download-"));
|
|
@@ -262,22 +756,78 @@ async function runDownloadAttempt(url, localPath, timeoutMs, signal) {
|
|
|
262
756
|
timedOut = true;
|
|
263
757
|
controller.abort();
|
|
264
758
|
}, timeoutMs);
|
|
759
|
+
const identity = safeDownloadUrlIdentity(url);
|
|
265
760
|
try {
|
|
266
761
|
if (callerAborted) {
|
|
267
762
|
throw new UrlDownloadError("cancelled", false, "Download cancelled");
|
|
268
763
|
}
|
|
269
|
-
await fetchToPartial(url, partialPath, controller);
|
|
270
|
-
syncAndPublishPartial(partialPath, localPath);
|
|
764
|
+
const integrity = await fetchToPartial(url, partialPath, controller, options);
|
|
765
|
+
let outcome = syncAndPublishPartial(partialPath, localPath);
|
|
766
|
+
let publishedIntegrity = integrity;
|
|
767
|
+
if (outcome === "race_reused") {
|
|
768
|
+
let inspection;
|
|
769
|
+
try {
|
|
770
|
+
inspection = await inspectExistingFile(localPath);
|
|
771
|
+
}
|
|
772
|
+
catch (error) {
|
|
773
|
+
if (error.code !== "ENOENT")
|
|
774
|
+
throw error;
|
|
775
|
+
throw new UrlDownloadError("filesystem", true, "Concurrent cache artifact disappeared before validation", integrity.status, integrity);
|
|
776
|
+
}
|
|
777
|
+
publishedIntegrity = {
|
|
778
|
+
...integrity,
|
|
779
|
+
localSize: inspection.localSize,
|
|
780
|
+
localSha256: inspection.localSha256,
|
|
781
|
+
};
|
|
782
|
+
if (!localInspectionMatchesOptions(inspection, options)) {
|
|
783
|
+
if (looksLikeRemoteErrorDocument(inspection.prefix)) {
|
|
784
|
+
throw new UrlDownloadError("invalid_payload", false, "Concurrent download published an HTML or JSON error document", integrity.status, publishedIntegrity);
|
|
785
|
+
}
|
|
786
|
+
throw checksumMismatchError("caller", integrity.status, publishedIntegrity);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
emitDownloadTelemetry(options, {
|
|
790
|
+
urlFingerprint: identity.urlFingerprint,
|
|
791
|
+
initialHost: identity.host,
|
|
792
|
+
attempt,
|
|
793
|
+
outcome,
|
|
794
|
+
...publishedIntegrity,
|
|
795
|
+
});
|
|
271
796
|
return localPath;
|
|
272
797
|
}
|
|
273
798
|
catch (error) {
|
|
274
799
|
if (callerAborted) {
|
|
275
|
-
|
|
800
|
+
const classified = new UrlDownloadError("cancelled", false, "Download cancelled");
|
|
801
|
+
emitDownloadTelemetry(options, {
|
|
802
|
+
urlFingerprint: identity.urlFingerprint,
|
|
803
|
+
initialHost: identity.host,
|
|
804
|
+
attempt,
|
|
805
|
+
outcome: "attempt_failed",
|
|
806
|
+
failureKind: classified.kind,
|
|
807
|
+
});
|
|
808
|
+
throw classified;
|
|
276
809
|
}
|
|
277
810
|
if (timedOut) {
|
|
278
|
-
|
|
811
|
+
const classified = new UrlDownloadError("timeout", true, `Download timeout after ${timeoutMs / 1000}s`);
|
|
812
|
+
emitDownloadTelemetry(options, {
|
|
813
|
+
urlFingerprint: identity.urlFingerprint,
|
|
814
|
+
initialHost: identity.host,
|
|
815
|
+
attempt,
|
|
816
|
+
outcome: "attempt_failed",
|
|
817
|
+
failureKind: classified.kind,
|
|
818
|
+
});
|
|
819
|
+
throw classified;
|
|
279
820
|
}
|
|
280
|
-
|
|
821
|
+
const classified = classifyDownloadFailure(error);
|
|
822
|
+
emitDownloadTelemetry(options, {
|
|
823
|
+
urlFingerprint: identity.urlFingerprint,
|
|
824
|
+
initialHost: identity.host,
|
|
825
|
+
attempt,
|
|
826
|
+
outcome: "attempt_failed",
|
|
827
|
+
failureKind: classified.kind,
|
|
828
|
+
...classified.telemetry,
|
|
829
|
+
});
|
|
830
|
+
throw classified;
|
|
281
831
|
}
|
|
282
832
|
finally {
|
|
283
833
|
clearTimeout(timeoutId);
|
|
@@ -286,30 +836,138 @@ async function runDownloadAttempt(url, localPath, timeoutMs, signal) {
|
|
|
286
836
|
rmSync(attemptDir, { recursive: true, force: true });
|
|
287
837
|
}
|
|
288
838
|
}
|
|
289
|
-
async function downloadWithRetry(url, localPath, timeoutMs, signal, onTransientRetry) {
|
|
839
|
+
async function downloadWithRetry(url, localPath, timeoutMs, signal, onTransientRetry, options = {}) {
|
|
290
840
|
const maxTransientRetries = 1;
|
|
291
841
|
for (let attempt = 0;; attempt += 1) {
|
|
292
842
|
try {
|
|
293
|
-
return await runDownloadAttempt(url, localPath, timeoutMs, signal);
|
|
843
|
+
return await runDownloadAttempt(url, localPath, timeoutMs, attempt + 1, options, signal);
|
|
294
844
|
}
|
|
295
845
|
catch (error) {
|
|
296
846
|
const classified = classifyDownloadFailure(error);
|
|
297
|
-
if (!classified.
|
|
847
|
+
if (!classified.locallyRetryable || attempt >= maxTransientRetries)
|
|
298
848
|
throw classified;
|
|
299
|
-
|
|
849
|
+
if (classified.retryable)
|
|
850
|
+
onTransientRetry?.(classified);
|
|
851
|
+
const identity = safeDownloadUrlIdentity(url);
|
|
852
|
+
emitDownloadTelemetry(options, {
|
|
853
|
+
urlFingerprint: identity.urlFingerprint,
|
|
854
|
+
initialHost: identity.host,
|
|
855
|
+
attempt: attempt + 1,
|
|
856
|
+
outcome: "retrying",
|
|
857
|
+
failureKind: classified.kind,
|
|
858
|
+
...classified.telemetry,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
async function inspectExistingFile(path) {
|
|
864
|
+
const sha256 = createHash("sha256");
|
|
865
|
+
const prefixChunks = [];
|
|
866
|
+
let prefixBytes = 0;
|
|
867
|
+
let localSize = 0;
|
|
868
|
+
for await (const chunk of createReadStream(path)) {
|
|
869
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
870
|
+
localSize += bytes.length;
|
|
871
|
+
sha256.update(bytes);
|
|
872
|
+
if (prefixBytes < 1024) {
|
|
873
|
+
const sample = bytes.subarray(0, 1024 - prefixBytes);
|
|
874
|
+
prefixChunks.push(sample);
|
|
875
|
+
prefixBytes += sample.length;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
localSize,
|
|
880
|
+
localSha256: sha256.digest("hex"),
|
|
881
|
+
prefix: Buffer.concat(prefixChunks),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
function localInspectionMatchesOptions(inspection, options) {
|
|
885
|
+
const expectedSha256 = options.expectedSha256?.trim().toLowerCase();
|
|
886
|
+
return (inspection.localSize > 0 &&
|
|
887
|
+
!looksLikeRemoteErrorDocument(inspection.prefix) &&
|
|
888
|
+
(!expectedSha256 || inspection.localSha256 === expectedSha256));
|
|
889
|
+
}
|
|
890
|
+
function sameCacheEntry(before, after) {
|
|
891
|
+
return (sameFileIdentity(before, after) &&
|
|
892
|
+
before.size === after.size &&
|
|
893
|
+
before.mtimeMs === after.mtimeMs);
|
|
894
|
+
}
|
|
895
|
+
// Cache identity checks must remain adjacent to invalidation to avoid widening the TOCTOU window.
|
|
896
|
+
// fallow-ignore-next-line complexity
|
|
897
|
+
async function reuseOrInvalidateCachedFile(url, localPath, timeoutMs, signal, options) {
|
|
898
|
+
const releaseLock = await acquireCachePathLock(localPath, timeoutMs, signal);
|
|
899
|
+
try {
|
|
900
|
+
// Re-inspect when a mixed-version process changes the path while it is open.
|
|
901
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
902
|
+
let before;
|
|
903
|
+
try {
|
|
904
|
+
before = lstatSync(localPath);
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
if (error.code === "ENOENT")
|
|
908
|
+
continue;
|
|
909
|
+
throw error;
|
|
910
|
+
}
|
|
911
|
+
if (!before.isFile() || before.size === 0) {
|
|
912
|
+
rmSync(localPath, { recursive: before.isDirectory(), force: true });
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
let inspection;
|
|
916
|
+
try {
|
|
917
|
+
inspection = await inspectExistingFile(localPath);
|
|
918
|
+
}
|
|
919
|
+
catch (error) {
|
|
920
|
+
if (error.code === "ENOENT")
|
|
921
|
+
continue;
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
let after;
|
|
925
|
+
try {
|
|
926
|
+
after = lstatSync(localPath);
|
|
927
|
+
}
|
|
928
|
+
catch (error) {
|
|
929
|
+
if (error.code === "ENOENT")
|
|
930
|
+
continue;
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
if (!sameCacheEntry(before, after))
|
|
934
|
+
continue;
|
|
935
|
+
if (localInspectionMatchesOptions(inspection, options)) {
|
|
936
|
+
const identity = safeDownloadUrlIdentity(url);
|
|
937
|
+
emitDownloadTelemetry(options, {
|
|
938
|
+
urlFingerprint: identity.urlFingerprint,
|
|
939
|
+
initialHost: identity.host,
|
|
940
|
+
attempt: 0,
|
|
941
|
+
outcome: "cache_hit",
|
|
942
|
+
receivedBytes: inspection.localSize,
|
|
943
|
+
localSize: inspection.localSize,
|
|
944
|
+
localSha256: inspection.localSha256,
|
|
945
|
+
rangeDisposition: "none",
|
|
946
|
+
});
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
rmSync(localPath, { force: true });
|
|
950
|
+
return false;
|
|
300
951
|
}
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
finally {
|
|
955
|
+
releaseLock();
|
|
301
956
|
}
|
|
302
957
|
}
|
|
303
|
-
export async function downloadToTemp(url, destDir, timeoutMs = 300000, signal, onTransientRetry) {
|
|
958
|
+
export async function downloadToTemp(url, destDir, timeoutMs = 300000, signal, onTransientRetry, options = {}) {
|
|
304
959
|
// Reject non-HTTPS URLs and private/reserved address ranges before
|
|
305
960
|
// touching the cache or filesystem — customer-supplied compositions must
|
|
306
961
|
// not be able to trigger outbound fetches to internal infrastructure.
|
|
307
962
|
assertPublicHttpsUrl(url);
|
|
963
|
+
const expectedSha256 = normalizeCallerSha256(options.expectedSha256);
|
|
964
|
+
const normalizedOptions = { ...options, expectedSha256 };
|
|
308
965
|
const cacheKey = `${url}\0${destDir}`;
|
|
309
966
|
// The physical request may be shared only by callers with the same
|
|
310
967
|
// cancellation scope and deadline. Otherwise the first caller's abort or
|
|
311
968
|
// timeout would incorrectly own every waiter.
|
|
312
|
-
const
|
|
969
|
+
const validationScope = expectedSha256 ?? "";
|
|
970
|
+
const inFlightKey = `${cacheKey}\0${timeoutMs}\0${signalScopeKey(signal)}\0${validationScope}`;
|
|
313
971
|
const inFlight = inFlightDownloads.get(inFlightKey);
|
|
314
972
|
if (inFlight) {
|
|
315
973
|
return inFlight;
|
|
@@ -317,11 +975,21 @@ export async function downloadToTemp(url, destDir, timeoutMs = 300000, signal, o
|
|
|
317
975
|
if (!existsSync(destDir)) {
|
|
318
976
|
mkdirSync(destDir, { recursive: true });
|
|
319
977
|
}
|
|
320
|
-
const filename = getFilenameFromUrl(url);
|
|
978
|
+
const filename = getFilenameFromUrl(url, validationScope);
|
|
321
979
|
const localPath = join(destDir, filename);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const downloadPromise =
|
|
980
|
+
// Register before the first asynchronous cache inspection so same-scope
|
|
981
|
+
// callers cannot both race through stale-entry invalidation.
|
|
982
|
+
const downloadPromise = (async () => {
|
|
983
|
+
const cacheStartedAt = Date.now();
|
|
984
|
+
const reused = await reuseOrInvalidateCachedFile(url, localPath, timeoutMs, signal, normalizedOptions);
|
|
985
|
+
const remainingTimeoutMs = timeoutMs - (Date.now() - cacheStartedAt);
|
|
986
|
+
if (remainingTimeoutMs <= 0) {
|
|
987
|
+
throw new UrlDownloadError("timeout", true, `Download cache inspection timeout after ${timeoutMs / 1000}s`);
|
|
988
|
+
}
|
|
989
|
+
if (reused)
|
|
990
|
+
return localPath;
|
|
991
|
+
return downloadWithRetry(url, localPath, remainingTimeoutMs, signal, onTransientRetry, normalizedOptions);
|
|
992
|
+
})();
|
|
325
993
|
const trackedDownload = downloadPromise.finally(() => {
|
|
326
994
|
inFlightDownloads.delete(inFlightKey);
|
|
327
995
|
});
|