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.
- package/dist/services/download-cache.js +554 -51
- package/dist/services/download-cache.js.map +1 -1
- package/dist/services/download-jobs.js +170 -31
- package/dist/services/download-jobs.js.map +1 -1
- package/dist/services/download-resume-diag.js +13 -0
- package/dist/services/download-resume-diag.js.map +1 -0
- package/dist/services/model-resolver.js +5 -1
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/services/storage/index.js +71 -0
- package/dist/services/storage/index.js.map +1 -1
- package/dist/tools/model-management.js +29 -1
- package/dist/tools/model-management.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
108
|
-
|
|
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
|
|
124
|
-
* Best-effort: a missing/unreadable sidecar just means "resume without an
|
|
125
|
-
*
|
|
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 =
|
|
129
|
-
|
|
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
|
|
136
|
-
* here only costs
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
316
|
-
// Content-Range total (NOT content-length, which is only the
|
|
317
|
-
// and would mask a short 206). For a 200 it is the
|
|
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
|
|
320
|
-
|
|
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
|
|
338
|
-
//
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
}
|