comfyui-mcp 0.48.16 → 0.48.17

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,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
- import { createWriteStream } from "node:fs";
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createWriteStream, constants as fsConstants } from "node:fs";
3
3
  import { copyFile, link, mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile, } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, extname, join, resolve } from "node:path";
@@ -9,7 +9,7 @@ import { ModelError } from "../utils/errors.js";
9
9
  import { logger } from "../utils/logger.js";
10
10
  import { redactUrlForLogs } from "./download-auth.js";
11
11
  import { reportDownloadProgress } from "./download-progress.js";
12
- import { downloadCloudUrlToFile, supportsCloudDownload, } from "./storage/index.js";
12
+ import { cloudPrincipalKey, downloadCloudUrlToFile, supportsCloudDownload, } from "./storage/index.js";
13
13
  const DEFAULT_CACHE_DIR = join(homedir(), ".comfyui-mcp", "cache");
14
14
  const HASH_CHARS = 32;
15
15
  const MAX_HTTP_REDIRECTS = 5;
@@ -36,6 +36,19 @@ async function fileSizeOrUndefined(path) {
36
36
  return undefined;
37
37
  }
38
38
  }
39
+ /** POSITIVELY true only when `path` is confirmed discarded — it no longer exists
40
+ * (stat throws ENOENT) or exists but is empty (size 0). A non-ENOENT stat error
41
+ * (a transient fs hiccup) or a still-present non-empty file returns false, so a
42
+ * swallowed removal failure is never mistaken for a completed discard (#467). */
43
+ async function partialConfirmedDiscarded(path) {
44
+ try {
45
+ const st = await stat(path);
46
+ return typeof st?.size === "number" && st.size === 0;
47
+ }
48
+ catch (err) {
49
+ return err?.code === "ENOENT";
50
+ }
51
+ }
39
52
  /**
40
53
  * Extract the useful diagnostic bits from an error thrown by `fetch()` itself
41
54
  * (a network-layer failure, not an HTTP status). undici surfaces these as
@@ -104,8 +117,57 @@ function cacheSizeLimitBytes() {
104
117
  return 0;
105
118
  return raw * 1024 * 1024 * 1024;
106
119
  }
107
- function cachePathForUrl(url) {
108
- const hash = createHash("sha256").update(url).digest("hex").slice(0, HASH_CHARS);
120
+ /** Representation-affecting request headers, folded into the cache identity so a
121
+ * same-URL fetch carrying DIFFERENT auth/headers (two users' Bearer tokens, a
122
+ * Cookie/API-key, or ANY custom header like `X-Custom-Auth` that selects a
123
+ * user-scoped or gated representation) can NEVER share a cache entry / in-flight
124
+ * stream and install the wrong bytes (#467 P1-2). Query-param auth already
125
+ * varies the URL itself (applyDownloadAuth folds it in), so this covers the
126
+ * header case the URL can't. Hashes EVERY caller-supplied header (an allowlist
127
+ * would silently miss custom auth headers) — these are the request headers built
128
+ * from the caller's auth/config, NOT the volatile hop headers (Range/If-Range),
129
+ * which are added later inside streamUrlToFile and never reach here. Empty ⇒ no
130
+ * header line is added to the identity (the URL still carries the v2 namespace). */
131
+ function representationKey(headers) {
132
+ const relevant = Object.keys(headers)
133
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
134
+ .map((k) => `${k.toLowerCase()}=${headers[k]}`)
135
+ .join("\n");
136
+ return relevant ? createHash("sha256").update(relevant).digest("hex").slice(0, 12) : "";
137
+ }
138
+ /** Cache-identity namespace. Bumped when the identity SCHEME changes so entries
139
+ * from an older scheme can never be served under the new one. Critically this
140
+ * fixes a cross-auth leak (#467 P1-C): the PRE-header-aware code cached
141
+ * header-authenticated downloads under the BARE URL, so without a namespace a
142
+ * post-upgrade UNAUTHENTICATED caller (also bare-URL) could be served bytes that
143
+ * were cached earlier under someone's auth. We can't tell a legacy entry's
144
+ * provenance, so ALL new identities — authed AND unauthed — are prefixed, which
145
+ * orphans every legacy `sha256(url)` entry (a one-time re-download; orphans age
146
+ * out via LRU when COMFYUI_LRU_CACHE_SIZE_GB is set, else are inert on disk). */
147
+ const CACHE_NS = "v2";
148
+ function cacheIdentity(url, headers, storageAuth) {
149
+ const repr = representationKey(headers);
150
+ // The EFFECTIVE cloud principal (explicit storageAuth MERGED with env creds/
151
+ // endpoint/region), not just explicit storageAuth — so an env-authenticated cloud
152
+ // entry can't be served later under different/absent creds (#467 P1-B).
153
+ const cloud = supportsCloudDownload(url) ? cloudPrincipalKey(url, storageAuth) : "";
154
+ // Namespaced for BOTH authed and unauthed so neither can collide with a legacy
155
+ // bare-URL entry of unknown auth provenance (#467 P1-C). Representation-affecting
156
+ // HTTP headers AND cloud credentials each add a line so different auth can never
157
+ // share bytes (#467 P1-2). Empty discriminators are omitted, so unauthenticated
158
+ // public downloads stay stable under the v2 namespace.
159
+ let id = `${CACHE_NS}\n${url}`;
160
+ if (repr)
161
+ id += `\n${repr}`;
162
+ if (cloud)
163
+ id += `\ncloud:${cloud}`;
164
+ return id;
165
+ }
166
+ function cachePathForUrl(url, headers = {}, storageAuth) {
167
+ const hash = createHash("sha256")
168
+ .update(cacheIdentity(url, headers, storageAuth))
169
+ .digest("hex")
170
+ .slice(0, HASH_CHARS);
109
171
  let extension = "";
110
172
  try {
111
173
  extension = extname(basename(new URL(url).pathname));
@@ -120,30 +182,73 @@ async function touch(path) {
120
182
  const now = new Date();
121
183
  await downloadCacheFs.utimes(path, now, now);
122
184
  }
123
- /** Read a persisted resume validator (ETag / Last-Modified) or null if absent.
124
- * Best-effort: a missing/unreadable sidecar just means "resume without an
125
- * If-Range guard" — never fatal. */
185
+ /** Read the resume sidecar (line 1 = validator, optional line 2 = total) or null.
186
+ * Best-effort: a missing/unreadable sidecar just means "resume without an If-Range
187
+ * guard" — never fatal. Backward compatible with pre-total single-line sidecars. */
126
188
  async function readValidatorSidecar(path) {
127
189
  try {
128
- const raw = (await readFile(path, "utf-8")).trim();
129
- return raw.length > 0 ? raw : null;
190
+ const raw = await readFile(path, "utf-8");
191
+ const lines = raw.split("\n");
192
+ const validator = (lines[0] ?? "").trim();
193
+ if (validator.length === 0)
194
+ return null;
195
+ const total = lines.length > 1 ? Number(lines[1].trim()) : NaN;
196
+ return Number.isFinite(total) && total > 0 ? { validator, total } : { validator };
130
197
  }
131
198
  catch {
132
199
  return null;
133
200
  }
134
201
  }
135
- /** Persist the resume validator next to a .partial. Best-effort a failure
136
- * here only costs us the change-detection guard on a later resume. */
137
- async function writeValidatorSidecar(path, value) {
202
+ /** Persist the resume validator (+ authoritative total when known) next to a
203
+ * .partial. Best-effort — a failure here only costs the change-detection guard on
204
+ * a later resume. The validator is a single-line header value; the optional total
205
+ * goes on line 2. */
206
+ async function writeValidatorSidecar(path, value, total) {
138
207
  try {
139
- await writeFile(path, value, "utf-8");
208
+ const body = typeof total === "number" && total > 0 ? `${value}\n${total}` : value;
209
+ await writeFile(path, body, "utf-8");
140
210
  }
141
211
  catch {
142
212
  /* best effort */
143
213
  }
144
214
  }
145
- async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLogs(url), storageAuth = {}, resumeFromBytes = 0, progress, resumable = false) {
215
+ /** The strongest resume validator a response can offer, preferring Hugging
216
+ * Face's content-addressed `X-Linked-Etag` (the LFS/Xet object hash — carried
217
+ * on the huggingface.co/resolve 302, NOT on the CAS CDN's final 200) over a
218
+ * plain ETag, falling back to Last-Modified. Capturing X-Linked-Etag from the
219
+ * redirect is what lets HF Xet downloads persist a sidecar at all (#467): the
220
+ * final CAS response returns neither ETag nor Last-Modified, so without this a
221
+ * Xet partial could NEVER be safely resumed. */
222
+ function extractValidator(res) {
223
+ return (res.headers.get("x-linked-etag") ||
224
+ res.headers.get("etag") ||
225
+ res.headers.get("last-modified"));
226
+ }
227
+ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLogs(url), storageAuth = {}, resumeFromBytes = 0, progress, resumable = false,
228
+ /** Sink for THIS physical download's resume decision, threaded from the job so
229
+ * the outcome is stored on that job — never in a shared keyed map that could
230
+ * misattribute it to another job (#467). Absent for internal/direct callers. */
231
+ onResume) {
146
232
  if (supportsCloudDownload(url)) {
233
+ // Cloud downloaders (S3/Azure) don't range-resume — they overwrite the target.
234
+ // If a partial exists it's being discarded; surface that (#467) instead of a
235
+ // silent restart. Truncate it OURSELVES first and CONFIRM (throw on failure)
236
+ // BEFORE reporting discarded:true — the cloud SDKs validate the request/body
237
+ // before opening their write stream, so reporting pre-download could otherwise
238
+ // claim a discard that a later auth/network failure never performed (P1-1).
239
+ if (resumable && resumeFromBytes > 0) {
240
+ try {
241
+ await writeFile(targetPath, "");
242
+ }
243
+ catch (err) {
244
+ throw new ModelError(`Download restart failed: could not truncate the stale ${resumeFromBytes}-byte partial ` +
245
+ `before a cloud re-download. Retry (a fresh attempt restarts from 0).`, { url: logUrl, cause: err instanceof Error ? err.message : String(err) });
246
+ }
247
+ logger.warn(`Discarded a ${resumeFromBytes}-byte partial download and restarting from 0: cloud ` +
248
+ `downloads (S3/Azure) don't support byte-range resume, so the partial was overwritten — ` +
249
+ `re-downloading in full.`, { url: logUrl, discardedBytes: resumeFromBytes });
250
+ onResume?.({ outcome: "declined:full-response", discardedBytes: resumeFromBytes, discarded: true });
251
+ }
147
252
  await downloadCloudUrlToFile(url, targetPath, storageAuth);
148
253
  // #343 edge: the S3/Azure path bypasses the HTTP size gate below. The cloud
149
254
  // downloaders verify their own Content-Length (truncation), but a stream
@@ -181,27 +286,96 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
181
286
  // validator — we cannot detect a changed file, so we must NOT append: fall
182
287
  // back to a clean restart (Range omitted, the "w" flag truncates the partial).
183
288
  let effectiveResume = resumeFromBytes;
289
+ // Did we actually ask the server to resume (Range + If-Range)? Used after the
290
+ // response to distinguish a taken resume (206) from an If-Range MISS (200 =
291
+ // upstream changed) so we can surface WHY the partial was discarded (#467).
292
+ let requestedResume = false;
293
+ // Set when a partial existed but no sidecar validator did, so we declined to
294
+ // resume. The discard is only REPORTED once we actually truncate it (after a
295
+ // successful response), so a fetch/redirect/status failure before then can't
296
+ // falsely claim the partial was discarded (#467 codex round 4).
297
+ let resumeDeclinedNoValidator = false;
298
+ // The persisted content-addressed validator we are resuming AGAINST — kept so
299
+ // we can re-compare it to the value the resolve redirect reports NOW, and
300
+ // refuse the append ourselves if they differ (belt-and-braces on top of the
301
+ // origin's If-Range: we never trust a CAS 206 whose upstream object changed).
302
+ let priorValidator = null;
303
+ // The authoritative full-file size the ORIGINAL response declared (persisted in
304
+ // the sidecar), if known — a resume 206 whose Content-Range total DISAGREES with
305
+ // this is a server understating the size, and must be refused (#467).
306
+ let priorTotal;
184
307
  if (resumeFromBytes > 0) {
185
- const priorValidator = resumable
186
- ? await readValidatorSidecar(validatorSidecar)
187
- : null;
308
+ const sidecar = resumable ? await readValidatorSidecar(validatorSidecar) : null;
309
+ priorValidator = sidecar?.validator ?? null;
310
+ priorTotal = sidecar?.total;
188
311
  if (priorValidator) {
189
312
  currentHeaders = {
190
313
  ...currentHeaders,
191
314
  Range: `bytes=${resumeFromBytes}-`,
192
315
  "If-Range": priorValidator,
193
316
  };
317
+ requestedResume = true;
194
318
  }
195
319
  else {
196
- // No trustworthy validator → discard the un-verifiable partial state.
320
+ // No trustworthy validator → we will discard the un-verifiable partial and
321
+ // restart (the deliberate #343 safety fallback). This USED to be silent: a
322
+ // multi-GB HF Xet partial (whose CAS CDN sent no ETag/Last-Modified, so no
323
+ // sidecar was ever written) got thrown away and re-downloaded from 0 with
324
+ // no log and no signal (#467). Defer the log/diagnostic until we actually
325
+ // truncate (below), so a pre-write failure can't falsely report a discard.
197
326
  effectiveResume = 0;
327
+ if (resumable)
328
+ resumeDeclinedNoValidator = true;
198
329
  }
199
330
  }
331
+ // The content-addressed validator captured from the resolve REDIRECT (HF's
332
+ // 302 carries X-Linked-Etag — the file's LFS/Xet content hash — even though
333
+ // the final CAS 200 carries no validator). Strictly X-Linked-Etag: a generic
334
+ // ETag/Last-Modified on a 3xx describes the redirect/pointer resource, NOT the
335
+ // target file, so it must never be promoted to the file's validator. Used
336
+ // both as the sidecar fallback (so Xet downloads become resumable) AND as the
337
+ // resume-time change check below (#467).
338
+ let redirectValidator = null;
339
+ // Set if ANY hop in the redirect chain reports a content-addressed X-Linked-Etag
340
+ // that DIFFERS from the validator the partial was written against — proof the
341
+ // upstream object changed, even if a later/earlier hop happens to match. Closes
342
+ // the multi-hop hole where only the first (or last) value is inspected (#467).
343
+ let sawChangedRedirectValidator = false;
344
+ // Did the bytes ultimately come from a DIFFERENT origin than we requested (HF
345
+ // resolve → CAS CDN)? A cross-origin 206 can't lean on the requesting origin's
346
+ // If-Range — the CDN may honor a stale Range and 206 a CHANGED object — so a
347
+ // cross-origin resume append MUST independently prove the content is unchanged
348
+ // via the content-addressed X-Linked-Etag (#467/#343).
349
+ let crossOriginRedirect = false;
350
+ // The AUTHORITATIVE full-file size Hugging Face declares on the resolve redirect
351
+ // (X-Linked-Size — the true LFS/Xet object size), captured because the final CAS
352
+ // 200's Content-Length can UNDERSTATE it (a truncating proxy/CDN). Used as the
353
+ // expected total so a short body fails verification instead of finalizing as
354
+ // complete — the fresh-download analogue of the resume total cross-check (#467).
355
+ let redirectSize;
200
356
  let res;
201
357
  for (let redirectCount = 0;; redirectCount += 1) {
202
358
  res = await fetchOrThrow(currentUrl, { headers: currentHeaders, redirect: "manual" }, currentUrl === url ? logUrl : redactUrlForLogs(currentUrl));
203
359
  if (res.status < 300 || res.status >= 400)
204
360
  break;
361
+ // Capture the content-addressed resume validator off the redirect itself.
362
+ // HF's resolve URL 302s to the CAS CDN and carries X-Linked-Etag (the LFS/Xet
363
+ // object hash) on the 302; the final CAS 200 carries NO validator. Without
364
+ // this, Xet downloads never persisted a sidecar and could never resume (#467).
365
+ // ONLY X-Linked-Etag — a generic ETag on a 3xx is the pointer's, not the file's.
366
+ // Keep the LAST value seen (nearest the final object) for the sidecar/match,
367
+ // AND flag if ANY hop's value contradicts the persisted validator on a resume.
368
+ const hopValidator = res.headers.get("x-linked-etag");
369
+ if (hopValidator) {
370
+ redirectValidator = hopValidator;
371
+ if (requestedResume && priorValidator && hopValidator !== priorValidator) {
372
+ sawChangedRedirectValidator = true;
373
+ }
374
+ }
375
+ // Capture the authoritative object size the redirect declares (last non-empty).
376
+ const hopSize = Number(res.headers.get("x-linked-size"));
377
+ if (Number.isFinite(hopSize) && hopSize > 0)
378
+ redirectSize = hopSize;
205
379
  if (redirectCount >= MAX_HTTP_REDIRECTS) {
206
380
  throw new ModelError(`Download redirect limit exceeded (${MAX_HTTP_REDIRECTS}) — the model host kept ` +
207
381
  `redirecting (Hugging Face routes resolve URLs through the Xet/CAS CDN; a loop ` +
@@ -233,15 +407,25 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
233
407
  status: res.status,
234
408
  });
235
409
  }
236
- // Drop request headers (Authorization etc.) when the redirect crosses
237
- // origins. HF's resolve URL 302s to a *pre-signed* Xet/CAS URL on a
410
+ // Drop credential-bearing headers (Authorization etc.) when the redirect
411
+ // crosses origins. HF's resolve URL 302s to a *pre-signed* Xet/CAS URL on a
238
412
  // different host (e.g. cas-bridge.xethub.hf.co) that needs no auth — and
239
413
  // forwarding our HF Bearer token to a third-party CDN would leak it. This
240
- // matches how huggingface_hub follows the CAS redirect.
414
+ // matches how huggingface_hub follows the CAS redirect. BUT keep Range /
415
+ // If-Range: they are not credentials, and the pre-signed CAS URL supports
416
+ // byte-range requests — dropping them meant a resume's Range never reached
417
+ // the CDN, so HF Xet resumes always fell back to a full re-download (#467).
241
418
  const sameOrigin = new URL(nextUrl).origin === new URL(currentUrl).origin;
242
419
  currentUrl = nextUrl;
243
- if (!sameOrigin)
244
- currentHeaders = {};
420
+ if (!sameOrigin) {
421
+ crossOriginRedirect = true;
422
+ const preserved = {};
423
+ if (currentHeaders.Range)
424
+ preserved.Range = currentHeaders.Range;
425
+ if (currentHeaders["If-Range"])
426
+ preserved["If-Range"] = currentHeaders["If-Range"];
427
+ currentHeaders = preserved;
428
+ }
245
429
  }
246
430
  if (!res.ok) {
247
431
  // Civitai requires an account token for ALL downloads (401 keyless, 403
@@ -255,6 +439,78 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
255
439
  if (!res.body) {
256
440
  throw new ModelError("Download response has no body", { url: logUrl });
257
441
  }
442
+ // #467/#343 belt-and-braces on a 206 RESUME APPEND. A same-origin 206 is safe:
443
+ // the very server that evaluated our If-Range is the one serving the bytes, so
444
+ // a 206 already proves the resource is unchanged. A CROSS-ORIGIN 206 (HF resolve
445
+ // → CAS CDN) cannot lean on that — the CDN may honor a stale Range and 206 a
446
+ // CHANGED object regardless of the origin's If-Range — so we must independently
447
+ // prove the object is unchanged via the content-addressed X-Linked-Etag: it MUST
448
+ // be present AND equal the validator the partial was written against. We check
449
+ // BOTH the values captured off the 3xx redirects AND the FINAL response's own
450
+ // X-Linked-Etag — a matching redirect followed by a final CDN 206 carrying a
451
+ // DIFFERENT content hash must NOT slip through (#467 P0-2). We refuse any 206
452
+ // whose observed validators PROVE a change (present but different), cross-origin
453
+ // or not. Only 206s are gated — a 200 is a full body and restarts cleanly below.
454
+ // On refusal, drop the partial + sidecar so a retry is a clean full download.
455
+ if (requestedResume && res.status === 206) {
456
+ // The final response's OWN content-addressed validator (the CAS 206 usually
457
+ // omits it, but when present it describes exactly THESE bytes — authoritative).
458
+ const finalValidator = res.headers.get("x-linked-etag");
459
+ // Any content-addressed validator we observed that CONTRADICTS the partial's.
460
+ const provenChange = sawChangedRedirectValidator ||
461
+ (redirectValidator !== null && redirectValidator !== priorValidator) ||
462
+ (finalValidator !== null && finalValidator !== priorValidator);
463
+ // The validator that best binds THESE bytes: the final response's own, else
464
+ // the nearest redirect's. Used for the cross-origin "must be proven" check.
465
+ const boundValidator = finalValidator ?? redirectValidator;
466
+ const unprovenCrossOrigin = crossOriginRedirect && boundValidator !== priorValidator; // includes missing
467
+ if (provenChange || unprovenCrossOrigin) {
468
+ // provenChange (a validator we saw DIFFERS from the partial's) is a proven
469
+ // change; unprovenCrossOrigin alone (no validator to compare) is merely
470
+ // UNVERIFIABLE — report each honestly rather than always "changed".
471
+ const why = provenChange
472
+ ? "the upstream now reports a DIFFERENT content-addressed object (X-Linked-Etag changed" +
473
+ (finalValidator !== null && finalValidator !== priorValidator
474
+ ? " on the final response"
475
+ : " on a redirect hop") +
476
+ ")"
477
+ : "the resume crossed origins to a CDN that returned no content-addressed validator, so an unchanged upstream can't be proven";
478
+ // Remove the stale partial + sidecar, then CONFIRM the partial is actually
479
+ // gone (safeRm swallows failures) before claiming it — a swallowed rm
480
+ // failure must not be reported as "removed", and would otherwise leave a
481
+ // partial a retry re-hits (#467 P1-a). The declined outcome is accurate
482
+ // regardless (we refused to append); only the removal wording is conditional.
483
+ await safeRm(targetPath);
484
+ await safeRm(validatorSidecar);
485
+ const removed = await partialConfirmedDiscarded(targetPath);
486
+ onResume?.({
487
+ outcome: provenChange ? "declined:etag-changed" : "declined:unverifiable",
488
+ discardedBytes: resumeFromBytes,
489
+ discarded: removed,
490
+ });
491
+ const tail = removed
492
+ ? "Removed the stale partial so a retry restarts cleanly."
493
+ : "Could not remove the stale partial — a retry may repeat this rejection; delete the .partial manually if so.";
494
+ logger.warn(`Refusing to append a 206 and ${removed ? "discarded" : "abandoning"} a ${resumeFromBytes}-byte ` +
495
+ `partial: ${why} — appending would risk corrupting the file (#343). ${tail}`, { url: logUrl, discardedBytes: resumeFromBytes, partialRemoved: removed });
496
+ throw new ModelError(`Download resume rejected: ${why}. ${tail}`, { url: logUrl });
497
+ }
498
+ }
499
+ // A 206 to a request we did NOT range (a FRESH download, or a no-validator
500
+ // decline — effectiveResume === 0, no Range sent) is UNSOLICITED and unsafe: its
501
+ // body is only a partial slice, but the "w"/Content-Length path below would
502
+ // finalize that short prefix as a complete file (e.g. `bytes 0-1023/4096` with
503
+ // Content-Length 1024 → a 1 KiB file renamed into cache as the whole 4096-byte
504
+ // model). Refuse it — a no-Range request must be answered with 200 (#467 P0).
505
+ if (res.status === 206 && effectiveResume === 0) {
506
+ await safeRm(targetPath);
507
+ if (resumable)
508
+ await safeRm(validatorSidecar);
509
+ const cleared = await partialConfirmedDiscarded(targetPath);
510
+ throw new ModelError(`Download failed: the server returned "206 Partial Content" to a request that sent NO Range ` +
511
+ `header. An unsolicited partial response can't be finalized as a complete file (it would ` +
512
+ `silently truncate the model). ${cleared ? "Removed any partial; retry." : "Could not remove the partial — delete it manually and retry."}`, { url: logUrl, status: res.status });
513
+ }
258
514
  // Decide append vs truncate based on the response. We only append when we
259
515
  // actually asked for a range (effectiveResume > 0, i.e. we had a validator)
260
516
  // AND the server honoured it with a 206. Any other 2xx (typically 200) means
@@ -294,6 +550,18 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
294
550
  `the file, but the server sent ${contentRange ? `"${contentRange}"` : "no Content-Range"}. ` +
295
551
  `Refusing to append (would corrupt or truncate the file). Removed the partial; retry.`, { url: logUrl, status: res.status });
296
552
  }
553
+ // Cross-check the 206's total against the AUTHORITATIVE size the ORIGINAL
554
+ // response declared (persisted in the sidecar). A server that understates the
555
+ // total on resume — e.g. `bytes 4-7/8` for a file first seen as 4096 — would
556
+ // otherwise let a short prefix finalize as "complete" (#467). Refuse the
557
+ // mismatch (also catches a partial we already have that exceeds the new total).
558
+ if (priorTotal !== undefined && total !== priorTotal) {
559
+ await safeRm(targetPath);
560
+ await safeRm(validatorSidecar);
561
+ throw new ModelError(`Download resume rejected: the server now reports a total size of ${total} bytes, but the ` +
562
+ `original download recorded ${priorTotal}. The upstream size changed — appending would ` +
563
+ `corrupt or truncate the file. Refusing to append; retry from a clean restart.`, { url: logUrl, status: res.status });
564
+ }
297
565
  rangeTotal = total;
298
566
  }
299
567
  const flags = appendMode ? "a" : "w";
@@ -304,23 +572,80 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
304
572
  // begin writing, a truncated partial is already paired with a MATCHING
305
573
  // validator. On a 206 append the existing sidecar already matches — leave it.
306
574
  if (resumable && !appendMode) {
575
+ // Drop any stale sidecar, then EXPLICITLY truncate the stale partial to zero
576
+ // and verify that truncation SUCCEEDED before we either (a) pair a new
577
+ // validator with the file or (b) report the discard. Doing the truncation
578
+ // ourselves (create-or-truncate to 0) — rather than relying on the lazy "w"
579
+ // stream open below — lets us confirm the old prefix is gone up front. The
580
+ // ordering is the #343 safety invariant: a new validator must NEVER be paired
581
+ // with un-truncated stale bytes (a later If-Range 206 would then append fresh
582
+ // bytes onto a stale prefix and silently corrupt the file). If truncation
583
+ // fails, we write NO validator (a retry sees no sidecar → safe restart) and
584
+ // report nothing (the discard didn't actually happen).
307
585
  await safeRm(validatorSidecar);
308
- await safeRm(targetPath);
309
- const validator = res.headers.get("etag") || res.headers.get("last-modified");
586
+ // If we can't truncate the stale partial, FAIL rather than fall through to the
587
+ // "w" open below: a partial-truncate failure that the later "w" open then
588
+ // silently fixed would perform the discard WITHOUT reporting it (#467). By
589
+ // throwing here we guarantee the discard is either reported (truncation
590
+ // succeeded) or the download errors (never a silent discard). The sidecar was
591
+ // already removed, so a retry sees no validator → safe restart (#343).
592
+ try {
593
+ await writeFile(targetPath, "");
594
+ }
595
+ catch (err) {
596
+ throw new ModelError(`Download restart failed: could not truncate the stale ${resumeFromBytes}-byte partial to ` +
597
+ `re-download it. Removed its validator; retry (a fresh attempt restarts from 0).`, { url: logUrl, cause: err instanceof Error ? err.message : String(err) });
598
+ }
599
+ if (resumeFromBytes > 0 && requestedResume) {
600
+ // Asked to resume (had a validator, sent Range+If-Range) but got a full 200
601
+ // — the upstream changed OR the host doesn't support range resume. The
602
+ // partial is now truncated and the file is being re-downloaded in full.
603
+ logger.warn(`Discarded a ${resumeFromBytes}-byte partial download and restarting from 0: the server ` +
604
+ `answered the If-Range resume request with a full ${res.status} instead of a 206 — the ` +
605
+ `upstream file changed, or the host doesn't support resuming — so re-downloading in full.`, { url: logUrl, discardedBytes: resumeFromBytes });
606
+ onResume?.({ outcome: "declined:full-response", discardedBytes: resumeFromBytes, discarded: true });
607
+ }
608
+ else if (resumeFromBytes > 0 && resumeDeclinedNoValidator) {
609
+ // A partial existed but no sidecar validator did, so we never even sent a
610
+ // Range — a safe resume couldn't be verified (common on HF's Xet/CAS CDN,
611
+ // which omits ETag/Last-Modified on the body). Truncated + re-download full.
612
+ logger.warn(`Discarded a ${resumeFromBytes}-byte partial download and restarting from 0: no ` +
613
+ `ETag/Last-Modified validator was ever persisted for it, so a safe resume can't be ` +
614
+ `verified (common on Hugging Face's Xet/CAS CDN, which omits both headers on the file ` +
615
+ `body). This is the safety-first behavior — an unverifiable resume risks a corrupt file ` +
616
+ `(#343). Future downloads capture the validator from the resolve redirect so they CAN resume.`, { url: logUrl, discardedBytes: resumeFromBytes });
617
+ onResume?.({ outcome: "declined:no-validator", discardedBytes: resumeFromBytes, discarded: true });
618
+ }
619
+ // The file is now confirmed truncated (we threw otherwise), so a new validator
620
+ // can never pair with a stale prefix (#343). Prefer the final response's
621
+ // validator; fall back to one captured off the redirect chain (HF Xet: the CAS
622
+ // 200 has none, but the resolve 302 carried X-Linked-Etag). Persist the
623
+ // AUTHORITATIVE full-file size (this restart response is a full 200, so its
624
+ // Content-Length IS the total) so a later resume can reject a 206 that
625
+ // understates it (#467).
626
+ const validator = extractValidator(res) || redirectValidator;
627
+ // Authoritative total: the GREATER of Content-Length and HF's X-Linked-Size, so
628
+ // a later resume cross-checks against the TRUE size, not an understated one (#467).
629
+ const fullTotal = Math.max(Number(res.headers.get("content-length")) || 0, redirectSize ?? 0) || undefined;
310
630
  if (validator)
311
- await writeValidatorSidecar(validatorSidecar, validator);
631
+ await writeValidatorSidecar(validatorSidecar, validator, fullTotal);
632
+ }
633
+ else if (appendMode) {
634
+ // A resume was actually taken (validated 206 append). Record it so
635
+ // download_status can report the partial was reused, not discarded (#467).
636
+ onResume?.({ outcome: "resumed", discardedBytes: 0, discarded: false });
312
637
  }
313
638
  const nodeStream = Readable.fromWeb(res.body);
314
639
  const fileStream = createWriteStream(targetPath, { flags });
315
- // Truncation target = the true full-file size. For a validated 206 that is the
316
- // Content-Range total (NOT content-length, which is only the remaining slice
317
- // and would mask a short 206). For a 200 it is the Content-Length.
640
+ // Truncation/verification target = the true full-file size. For a validated 206
641
+ // that is the Content-Range total (NOT content-length, which is only the
642
+ // remaining slice and would mask a short 206). For a fresh/restart 200 it is the
643
+ // GREATER of the response's Content-Length and Hugging Face's authoritative
644
+ // X-Linked-Size — so a CDN that UNDERSTATES Content-Length can't finalize a short
645
+ // body as complete (assertComplete then requires the full X-Linked-Size) (#467).
318
646
  const lengthHeader = Number(res.headers.get("content-length") || 0);
319
- const expectedTotal = appendMode
320
- ? (rangeTotal ?? 0)
321
- : lengthHeader > 0
322
- ? lengthHeader
323
- : 0;
647
+ const fresh200Total = Math.max(lengthHeader > 0 ? lengthHeader : 0, redirectSize ?? 0);
648
+ const expectedTotal = appendMode ? (rangeTotal ?? 0) : fresh200Total;
324
649
  // A pipeline() can resolve on a stream that ended EARLY (server dropped the
325
650
  // connection, proxy cut it, disk edge case) — leaving a 0-byte or truncated
326
651
  // file that would otherwise be reported as a successful download (#343: silent
@@ -334,10 +659,21 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
334
659
  catch {
335
660
  actual = undefined;
336
661
  }
337
- // Couldn't read a real size (e.g. the fs layer is stubbed in tests, or stat
338
- // hiccuped) can't verify, so don't block a download over a missing number.
339
- if (actual === undefined)
662
+ // Couldn't read the written size. FAIL CLOSED when we have an authoritative
663
+ // expected total to check against (#467 P1-B): a transient stat failure must
664
+ // NOT let an early/oversized body be finalized (renamed into cache) as a
665
+ // success — that is exactly the silent-corruption class #343 guards. Keep the
666
+ // partial on disk (it may still be range-resumable) and error instead. Only
667
+ // when NO expected total is known (server sent no Content-Length/Content-Range)
668
+ // do we return — there is nothing to verify against, so a missing size can't
669
+ // prove corruption and mustn't block the download.
670
+ if (actual === undefined) {
671
+ if (expectedTotal > 0) {
672
+ throw new ModelError(`Download could not be verified: expected ${expectedTotal} bytes but the written file size ` +
673
+ `couldn't be read — not finalizing (the file may be incomplete). Retry.`, { url: logUrl });
674
+ }
340
675
  return;
676
+ }
341
677
  if (actual === 0) {
342
678
  // Nothing landed — remove it (and its validator sidecar) so it can't
343
679
  // masquerade as a real file / poison a resume with a validator that has
@@ -352,6 +688,20 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
352
688
  // but do NOT report this as a completed download.
353
689
  throw new ModelError(`Download truncated: wrote ${actual} of ${expectedTotal} bytes — the stream ended early. Not complete; retry to resume.`, { url: logUrl });
354
690
  }
691
+ if (expectedTotal > 0 && actual > expectedTotal) {
692
+ // OVERSIZED — the server streamed MORE than the authoritative size (a 206
693
+ // Content-Range total, or a 200 Content-Length). A validated resume that
694
+ // claims `bytes 4-7/8` but streams extra bytes would otherwise finalize a
695
+ // corrupt file with no error (#467 P0-1). This is NOT resumable — the bytes
696
+ // on disk are wrong — so remove the partial + validator and fail; a retry
697
+ // starts clean rather than range-resuming a corrupt prefix.
698
+ await safeRm(targetPath);
699
+ if (resumable)
700
+ await safeRm(validatorSidecar);
701
+ throw new ModelError(`Download oversized: wrote ${actual} bytes but the file is only ${expectedTotal} — the ` +
702
+ `response sent more data than its declared size (corrupt or misbehaving server). Removed ` +
703
+ `the bad file; retry.`, { url: logUrl });
704
+ }
355
705
  };
356
706
  // No progress wanted (internal/cache caller, or not under the panel) → straight pipe.
357
707
  if (!progress) {
@@ -397,10 +747,17 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
397
747
  throw err;
398
748
  }
399
749
  }
400
- async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progress) {
401
- const target = cachePathForUrl(url);
750
+ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progress, onResume) {
751
+ // Representation-aware identity (#467): a same-URL download with different HTTP
752
+ // auth headers OR different cloud (S3/Azure) credentials gets its OWN cache file,
753
+ // partial and in-flight slot — never coalesced onto another caller's stream.
754
+ const target = cachePathForUrl(url, headers, storageAuth);
402
755
  const key = target;
403
756
  const existing = inflight.get(key);
757
+ // A job COALESCING onto an in-flight physical download gets no resume decision
758
+ // of its own — the decision is reported to the job that actually runs the
759
+ // stream (#467). This is inherent to the callback model: onResume is not passed
760
+ // to the shared promise, so a coalesced caller simply awaits the same result.
404
761
  if (existing)
405
762
  return existing;
406
763
  const promise = (async () => {
@@ -417,6 +774,8 @@ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progres
417
774
  }
418
775
  else {
419
776
  await touch(target);
777
+ // Cache hit ⇒ no resume/discard this attempt; onResume is never called,
778
+ // so the job's resume field stays empty (nothing to surface).
420
779
  return target;
421
780
  }
422
781
  }
@@ -444,7 +803,8 @@ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progres
444
803
  // No partial — fresh download.
445
804
  }
446
805
  try {
447
- await streamUrlToFile(url, partial, headers, logUrl, storageAuth, resumeFromBytes, progress, true);
806
+ await streamUrlToFile(url, partial, headers, logUrl, storageAuth, resumeFromBytes, progress, true, // resumable: cache partials use the .partial + If-Range resume handshake
807
+ onResume);
448
808
  await downloadCacheFs.rename(partial, target);
449
809
  await touch(target);
450
810
  return target;
@@ -477,17 +837,144 @@ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progres
477
837
  inflight.delete(key);
478
838
  }
479
839
  }
840
+ /** A cryptographically-random, unguessable temp path next to `base`. NOT a
841
+ * predictable pid+counter name (#467 P1-A): a predictable name can collide with a
842
+ * temp left by a crashed attempt or a reused PID, and — if that leftover is still
843
+ * hardlinked to a cache inode — a non-exclusive create would truncate it and
844
+ * corrupt that cache entry. Random + exclusive-create makes a collision effectively
845
+ * impossible AND detectable (EEXIST), so we never write over a pre-existing file. */
846
+ function randomTempPath(base, tag) {
847
+ return `${base}.${tag}-${randomBytes(12).toString("hex")}.tmp`;
848
+ }
849
+ /** Reserve an unguessable temp path by creating it EXCLUSIVELY (O_EXCL via the "wx"
850
+ * flag), retrying on the astronomically-unlikely EEXIST. Guarantees the returned
851
+ * path is a FRESH standalone inode — so a subsequent "w" open (streamUrlToFile /
852
+ * the S3/Azure downloaders truncate) writes to our own new file and can NEVER
853
+ * follow a pre-existing hardlink into a cache inode (#467 P1-A). */
854
+ async function reserveExclusiveTemp(base, tag) {
855
+ for (let attempt = 1;; attempt++) {
856
+ const p = randomTempPath(base, tag);
857
+ try {
858
+ await writeFile(p, "", { flag: "wx" });
859
+ return p;
860
+ }
861
+ catch (err) {
862
+ if (err?.code === "EEXIST" && attempt < MATERIALIZE_TEMP_ATTEMPTS) {
863
+ continue;
864
+ }
865
+ throw err;
866
+ }
867
+ }
868
+ }
869
+ /** True for a hardlink error that means "hardlinks aren't usable here" — the ONLY
870
+ * case where copy-fallback is legitimate. NOT EEXIST (a name collision — retry a
871
+ * new name; copy-falling-back there is what truncates a stale hardlinked temp and
872
+ * poisons a cache inode, #467 P1-A) and NOT other errors (propagate). */
873
+ function isHardlinkUnsupported(code) {
874
+ // ONLY genuine "hardlinks aren't usable here" codes: cross-device (EXDEV), the FS
875
+ // doesn't support links (ENOSYS/EPERM — some Windows/network FS). NOT EACCES — an
876
+ // ACL denial is a real permission error that must propagate, not silently copy
877
+ // (#467 P2).
878
+ return code === "EXDEV" || code === "EPERM" || code === "ENOSYS";
879
+ }
880
+ /** Rename `tmp` over `targetPath`. On POSIX, rename atomically replaces an existing
881
+ * destination. Windows can't rename OVER an existing file and returns EPERM/EACCES/
882
+ * EEXIST — ONLY then do we move the destination ASIDE and swap. Any OTHER rename
883
+ * error (ENOENT, EIO, EXDEV, …) must NOT touch a valid existing destination (#467):
884
+ * clean our temp and propagate. */
885
+ async function renameTempOverDestination(tmp, targetPath) {
886
+ try {
887
+ await downloadCacheFs.rename(tmp, targetPath);
888
+ return;
889
+ }
890
+ catch (err) {
891
+ const code = err?.code;
892
+ const windowsOverwrite = process.platform === "win32" &&
893
+ (code === "EPERM" || code === "EACCES" || code === "EEXIST");
894
+ if (!windowsOverwrite) {
895
+ await downloadCacheFs.rm(tmp, { force: true }).catch(() => undefined);
896
+ throw err;
897
+ }
898
+ // Windows: move the existing destination ASIDE to a backup, then swap the temp
899
+ // in. On ANY swap failure, RESTORE the backup so a valid destination is never
900
+ // lost. If even the restore fails, the original is INTACT at `backup` — surface
901
+ // that path in the error so it's recoverable, never silently stranded (#467).
902
+ const backup = `${targetPath}.bak-${randomBytes(9).toString("hex")}.tmp`;
903
+ let backedUp = false;
904
+ try {
905
+ await downloadCacheFs.rename(targetPath, backup);
906
+ backedUp = true;
907
+ }
908
+ catch {
909
+ /* destination may not exist — nothing to move aside */
910
+ }
911
+ try {
912
+ await downloadCacheFs.rename(tmp, targetPath);
913
+ if (backedUp)
914
+ await downloadCacheFs.rm(backup, { force: true }).catch(() => undefined);
915
+ }
916
+ catch (e) {
917
+ await downloadCacheFs.rm(tmp, { force: true }).catch(() => undefined);
918
+ if (backedUp) {
919
+ const restored = await downloadCacheFs
920
+ .rename(backup, targetPath)
921
+ .then(() => true)
922
+ .catch(() => false);
923
+ if (!restored) {
924
+ throw new ModelError(`Download could not be finalized and the destination could not be restored — your ` +
925
+ `previous file is PRESERVED at "${backup}"; move it back to "${targetPath}" manually. ` +
926
+ `Cause: ${e instanceof Error ? e.message : String(e)}`, { url: targetPath });
927
+ }
928
+ }
929
+ throw e;
930
+ }
931
+ }
932
+ }
933
+ const MATERIALIZE_TEMP_ATTEMPTS = 5;
480
934
  async function materializeCacheFile(cachePath, targetPath) {
481
935
  if (resolve(cachePath) === resolve(targetPath))
482
936
  return "hardlink";
483
- await downloadCacheFs.rm(targetPath, { force: true });
484
- try {
485
- await downloadCacheFs.link(cachePath, targetPath);
486
- return "hardlink";
487
- }
488
- catch {
489
- await downloadCacheFs.copyFile(cachePath, targetPath);
490
- return "copy";
937
+ // Materialize ATOMICALLY into an UNGUESSABLE, EXCLUSIVELY-created temp, then
938
+ // rename over targetPath — never write directly at targetPath, and never reuse a
939
+ // possibly-stale temp name (#467 P1-A). Two jobs materializing DIFFERENT
940
+ // representations to the SAME path can race; building each in its own random temp
941
+ // (a hardlink to, or an EXCLUSIVE copy of, our OWN cache inode) keeps every cache
942
+ // inode read-only here and makes the final swap atomic (last-writer-wins on the
943
+ // destination only, never a cache entry).
944
+ for (let attempt = 1;; attempt++) {
945
+ const tmp = randomTempPath(targetPath, "mat");
946
+ let mode;
947
+ try {
948
+ await downloadCacheFs.link(cachePath, tmp);
949
+ mode = "hardlink";
950
+ }
951
+ catch (err) {
952
+ const code = err?.code;
953
+ // Name collision → pick a fresh random name (never copy-fall-back onto a
954
+ // possibly-stale, possibly-cache-hardlinked temp).
955
+ if (code === "EEXIST") {
956
+ if (attempt >= MATERIALIZE_TEMP_ATTEMPTS)
957
+ throw err;
958
+ continue;
959
+ }
960
+ // Only fall back to copy when hardlinks genuinely aren't usable here.
961
+ if (!isHardlinkUnsupported(code))
962
+ throw err;
963
+ try {
964
+ // EXCLUSIVE copy: fail (EEXIST) rather than truncate a pre-existing file.
965
+ await downloadCacheFs.copyFile(cachePath, tmp, fsConstants.COPYFILE_EXCL);
966
+ }
967
+ catch (e) {
968
+ const ecode = e?.code;
969
+ await downloadCacheFs.rm(tmp, { force: true }).catch(() => undefined);
970
+ if (ecode === "EEXIST" && attempt < MATERIALIZE_TEMP_ATTEMPTS)
971
+ continue;
972
+ throw e;
973
+ }
974
+ mode = "copy";
975
+ }
976
+ await renameTempOverDestination(tmp, targetPath);
977
+ return mode;
491
978
  }
492
979
  }
493
980
  async function evictLruIfNeeded() {
@@ -524,7 +1011,7 @@ export async function downloadUrlToFile(url, targetPath, headers, logUrl, storag
524
1011
  export async function downloadWithCache(options) {
525
1012
  const logUrl = options.logUrl ?? redactUrlForLogs(options.url);
526
1013
  try {
527
- const cachePath = await downloadIntoCache(options.url, options.headers, logUrl, options.storageAuth, options.progress);
1014
+ const cachePath = await downloadIntoCache(options.url, options.headers, logUrl, options.storageAuth, options.progress, options.onResume);
528
1015
  const materializedBy = await materializeCacheFile(cachePath, options.targetPath);
529
1016
  await evictLruIfNeeded();
530
1017
  return {
@@ -541,7 +1028,23 @@ export async function downloadWithCache(options) {
541
1028
  url: logUrl,
542
1029
  error: err instanceof Error ? err.message : String(err),
543
1030
  });
544
- await downloadUrlToFile(options.url, options.targetPath, options.headers, logUrl, options.storageAuth, options.progress);
1031
+ // Download to an EXCLUSIVELY-created, unguessable temp then rename over the
1032
+ // destination — NEVER stream "w" directly at targetPath (#467). If a concurrent
1033
+ // materialize already hardlinked targetPath to a cache inode, a direct "w" open
1034
+ // would follow that hardlink and overwrite the OTHER representation's cache
1035
+ // entry with these bytes (poison). Reserving the temp with O_EXCL guarantees a
1036
+ // fresh standalone inode, so the downloader's "w"/truncate can never follow a
1037
+ // pre-existing hardlink; rename only swaps the directory entry. A failed stream
1038
+ // leaves the destination untouched.
1039
+ const tmp = await reserveExclusiveTemp(options.targetPath, "dl");
1040
+ try {
1041
+ await downloadUrlToFile(options.url, tmp, options.headers, logUrl, options.storageAuth, options.progress);
1042
+ await renameTempOverDestination(tmp, options.targetPath);
1043
+ }
1044
+ catch (e) {
1045
+ await downloadCacheFs.rm(tmp, { force: true }).catch(() => undefined);
1046
+ throw e;
1047
+ }
545
1048
  return { targetPath: options.targetPath, usedCache: false };
546
1049
  }
547
1050
  }