@hyperframes/engine 0.7.92 → 0.7.94

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.
@@ -1,8 +1,8 @@
1
- import { closeSync, createWriteStream, existsSync, fsyncSync, mkdtempSync, mkdirSync, lstatSync, openSync, renameSync, rmSync, statSync, } from "fs";
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
- constructor(kind, retryable, message, status) {
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, statusText) {
34
- const message = `HTTP ${status}: ${statusText}`;
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, `Download failed: ${message}`);
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, `Download failed: ${message}`);
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(`[URLDownloader] Invalid URL: ${url}`);
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 (got ${parsed.protocol}): ${url}`);
162
+ throw new Error(`[URLDownloader] Only HTTPS URLs are permitted in compositions`);
130
163
  }
131
164
  if (isBlockedHost(parsed.hostname)) {
132
- throw new Error(`[URLDownloader] URL targets a private/reserved address and is not permitted: ${url}`);
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 hash = createHash("md5").update(url).digest("hex").slice(0, 12);
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 hasCompleteFile(path) {
142
- if (!existsSync(path))
143
- return false;
144
- const entry = lstatSync(path);
145
- if (entry.isFile() && entry.size > 0)
146
- return true;
147
- // Old versions could leave an empty file behind. Never trust that stale
148
- // cache entry—or a symlink/special entry planted at the final path.
149
- rmSync(path, { recursive: entry.isDirectory(), force: true });
150
- return false;
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
- async function fetchToPartial(url, partialPath, controller) {
203
- const response = await fetchWithValidatedRedirects(url, controller);
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
- throw classifyHttpFailure(response.status, response.statusText);
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
- const fileStream = createWriteStream(partialPath, { flags: "wx" });
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
- await pipeline(readableStream, fileStream);
222
- if (statSync(partialPath).size === 0) {
223
- throw new UrlDownloadError("empty_body", true, "Download response body contained zero bytes");
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
- // Different cancellation scopes intentionally do not share a physical
237
- // request. If another complete attempt won the final-path race, reuse it.
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
- renameSync(partialPath, localPath);
709
+ linkSync(partialPath, localPath);
710
+ unlinkSync(partialPath);
711
+ return "published";
242
712
  }
243
713
  catch (error) {
244
- if (!hasCompleteFile(localPath))
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
- async function runDownloadAttempt(url, localPath, timeoutMs, signal) {
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
- throw new UrlDownloadError("cancelled", false, "Download cancelled");
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
- throw new UrlDownloadError("timeout", true, `Download timeout after ${timeoutMs / 1000}s`);
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
- throw classifyDownloadFailure(error);
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.retryable || attempt >= maxTransientRetries)
847
+ if (!classified.locallyRetryable || attempt >= maxTransientRetries)
298
848
  throw classified;
299
- onTransientRetry?.(classified);
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 inFlightKey = `${cacheKey}\0${timeoutMs}\0${signalScopeKey(signal)}`;
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
- if (hasCompleteFile(localPath))
323
- return localPath;
324
- const downloadPromise = downloadWithRetry(url, localPath, timeoutMs, signal, onTransientRetry);
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
  });