comfyui-mcp 0.48.20 → 0.48.21
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/comfyui/client.js +103 -28
- package/dist/comfyui/client.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +117 -1
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/services/download-cache.js +639 -12
- package/dist/services/download-cache.js.map +1 -1
- package/dist/tools/workflow-library.js +10 -5
- package/dist/tools/workflow-library.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { createWriteStream, constants as fsConstants } from "node:fs";
|
|
3
|
-
import { copyFile, link, mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { copyFile, link, mkdir, open, readFile, readdir, rename, rm, stat, utimes, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { basename, extname, join, resolve } from "node:path";
|
|
5
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
6
|
import { Readable, Transform } from "node:stream";
|
|
7
7
|
import { pipeline } from "node:stream/promises";
|
|
8
8
|
import { ModelError } from "../utils/errors.js";
|
|
@@ -24,6 +24,156 @@ async function safeRm(path) {
|
|
|
24
24
|
/* best effort */
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
/** Remove `path`, returning true iff it is confirmed gone. Unlike `safeRm` this
|
|
28
|
+
* LOGS a removal failure (EPERM/EACCES/…) instead of swallowing it silently — a
|
|
29
|
+
* left-behind rejected artifact (a poisoned partial / cache entry) must at least be
|
|
30
|
+
* visible, since a later cache-hit or resume could otherwise re-hit it (#473 P1). */
|
|
31
|
+
async function rmOrLog(path, logUrl) {
|
|
32
|
+
try {
|
|
33
|
+
await rm(path, { force: true });
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
logger.warn(`Failed to remove a rejected download artifact "${path}" — a retry may re-hit it; ` +
|
|
38
|
+
`delete it manually if the same rejection repeats.`, { url: logUrl, error: err instanceof Error ? err.message : String(err) });
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Remove `path`; if removal fails, NEUTRALIZE it by truncating to 0 bytes. Returns
|
|
43
|
+
* true iff the path ends up removed OR emptied. A 0-byte partial is treated as fresh
|
|
44
|
+
* (not resumed) and a 0-byte cache/sidecar entry carries no resumable state, so
|
|
45
|
+
* zeroing is as safe as deleting. Logs (error) only when it can do NEITHER (#473 P1). */
|
|
46
|
+
async function rmOrTruncate(path, logUrl) {
|
|
47
|
+
if (await rmOrLog(path, logUrl))
|
|
48
|
+
return true;
|
|
49
|
+
try {
|
|
50
|
+
await writeFile(path, "");
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
logger.error(`Could not remove OR truncate a rejected download artifact "${path}"; a retry may re-serve ` +
|
|
55
|
+
`or resume it. Delete this file manually.`, { url: logUrl, error: err instanceof Error ? err.message : String(err) });
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Discard a payload that was REJECTED as a non-model (HTML/JSON auth/error) body.
|
|
60
|
+
* Neutralizes BOTH the payload file AND its resume sidecar (remove, else truncate to
|
|
61
|
+
* 0). A resume requires a NONZERO partial AND a valid (nonempty) sidecar, so zeroing
|
|
62
|
+
* or removing EITHER makes a retry decline the resume and restart clean — the
|
|
63
|
+
* guarantee holds as long as at least one of the two can be neutralized, not only
|
|
64
|
+
* when the partial itself can be deleted (#473 P1). A hard failure to neutralize is
|
|
65
|
+
* surfaced via the log inside rmOrTruncate. */
|
|
66
|
+
async function discardRejectedPayload(path, sidecarPath, logUrl) {
|
|
67
|
+
const neutralized = await rmOrTruncate(path, logUrl);
|
|
68
|
+
if (sidecarPath)
|
|
69
|
+
await rmOrTruncate(sidecarPath, logUrl);
|
|
70
|
+
return neutralized;
|
|
71
|
+
}
|
|
72
|
+
/** Drop a tiny "this partial was rejected as a non-model body" MARKER next to a
|
|
73
|
+
* resumable partial. Best-effort. It is the cleanup-INDEPENDENT signal that lets a
|
|
74
|
+
* later attempt refuse to resume even when the rejection was based ONLY on the
|
|
75
|
+
* response Content-Type (gone by retry) and both removal and truncation of the
|
|
76
|
+
* partial+sidecar failed — a case body-magic alone can't re-derive (#473 P1). */
|
|
77
|
+
async function writePoisonMarker(markerPath, logUrl) {
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(markerPath, "rejected-non-model-payload");
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
logger.warn(`Could not write a poison marker "${markerPath}" for a rejected partial`, {
|
|
83
|
+
url: logUrl,
|
|
84
|
+
error: err instanceof Error ? err.message : String(err),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Path of the Content-Type sidecar beside a finalized CACHE file. A hidden dotfile
|
|
89
|
+
* (so LRU/readdir scans skip it) that records the response Content-Type, letting a
|
|
90
|
+
* later cache-HIT / coalesced caller re-run the #473 model-payload check WITH the
|
|
91
|
+
* original Content-Type instead of only body magic — closing the reuse gap where a
|
|
92
|
+
* Content-Type-only-flaggable body (its bytes don't sniff as text) could otherwise
|
|
93
|
+
* finalize as a model on a subsequent request. */
|
|
94
|
+
function cacheCtSidecar(cacheFilePath) {
|
|
95
|
+
return join(dirname(cacheFilePath), `.${basename(cacheFilePath)}.ct`);
|
|
96
|
+
}
|
|
97
|
+
/** True when a Content-Type is one the #473 gate would reject for a binary-model
|
|
98
|
+
* destination (an HTML page or a JSON document). Kept in lockstep with the
|
|
99
|
+
* Content-Type belt in detectNonModelPayload. */
|
|
100
|
+
function isRejectableContentType(contentType) {
|
|
101
|
+
return (/text\/html|application\/xhtml/i.test(contentType) || /(^|[/+])json\b/i.test(contentType));
|
|
102
|
+
}
|
|
103
|
+
/** Reconcile the Content-Type sidecar beside a freshly-finalized cache file. Writes it
|
|
104
|
+
* ONLY for the reject-worthy shapes (text/html, application/json) — persisting every
|
|
105
|
+
* octet-stream/text-plain type would add a useless sidecar per cache entry, and only
|
|
106
|
+
* an HTML/JSON label can flag a body that body-magic alone would miss on reuse. For a
|
|
107
|
+
* NON-reject-worthy (e.g. octet-stream) fill it REMOVES any pre-existing sidecar: a
|
|
108
|
+
* clean model landing under a cache key that a prior HTML response had tagged
|
|
109
|
+
* `text/html` must not inherit that stale tag and be wrongly rejected on reuse (#473 —
|
|
110
|
+
* a false-positive against a legitimate model). */
|
|
111
|
+
async function writeCacheContentType(cacheFilePath, contentType) {
|
|
112
|
+
const sidecar = cacheCtSidecar(cacheFilePath);
|
|
113
|
+
if (!contentType || !isRejectableContentType(contentType)) {
|
|
114
|
+
// Clear any stale sidecar so it can't attach to these clean bytes. Even if this
|
|
115
|
+
// rm fails, the SIZE stamp below makes a stale sidecar self-invalidate on read.
|
|
116
|
+
await rm(sidecar, { force: true }).catch(() => undefined);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
// Stamp a fingerprint of the payload (size + a hash of its sniff head) alongside
|
|
121
|
+
// the Content-Type. On read we honor the type only when that fingerprint still
|
|
122
|
+
// matches the cache file — so a stale sidecar left behind after a clean re-fill
|
|
123
|
+
// (different bytes ⇒ different head/size) is ignored and can NEVER reject a
|
|
124
|
+
// legitimate model, regardless of whether the clearing rm above succeeded, AND
|
|
125
|
+
// even in the astronomically-unlikely case where the replacement model has the
|
|
126
|
+
// exact same byte LENGTH as the prior page (the head hash still differs) (#473 —
|
|
127
|
+
// robust against the swallowed-delete false-positive).
|
|
128
|
+
await writeFile(sidecar, `${contentType}\n${await payloadFingerprint(cacheFilePath)}`);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
// Best effort — reuse falls back to body-magic-only validation, which still
|
|
132
|
+
// catches every well-formed HTML/JSON page (only a synthetic body that sniffs
|
|
133
|
+
// binary yet carries a text Content-Type would evade). Surface it rather than
|
|
134
|
+
// swallow so the degraded reuse check is at least visible (#473).
|
|
135
|
+
logger.warn(`Could not persist the Content-Type for a cached download at "${cacheFilePath}"; ` +
|
|
136
|
+
`cache-reuse validation falls back to body magic only.`, { error: err instanceof Error ? err.message : String(err) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Read the persisted Content-Type for a cache file, or "" when absent/unreadable OR
|
|
140
|
+
* STALE. The sidecar stamps the payload size it was written for; if that size no
|
|
141
|
+
* longer matches the cache file on disk the sidecar describes DIFFERENT (since
|
|
142
|
+
* replaced) bytes and is ignored — so a leftover `text/html` tag can't reject a
|
|
143
|
+
* legitimate model that later re-filled the same cache slot (#473). */
|
|
144
|
+
async function readCacheContentType(cacheFilePath) {
|
|
145
|
+
try {
|
|
146
|
+
const raw = await readFile(cacheCtSidecar(cacheFilePath), "utf-8");
|
|
147
|
+
const nl = raw.indexOf("\n");
|
|
148
|
+
if (nl < 0)
|
|
149
|
+
return ""; // no fingerprint (unexpected) → don't trust it
|
|
150
|
+
const contentType = raw.slice(0, nl).trim();
|
|
151
|
+
const stamped = raw.slice(nl + 1).trim();
|
|
152
|
+
if (!contentType || !stamped)
|
|
153
|
+
return "";
|
|
154
|
+
const current = await payloadFingerprint(cacheFilePath);
|
|
155
|
+
// Only honor the tag when it was stamped for the bytes currently on disk. A
|
|
156
|
+
// mismatch (stale sidecar over re-filled bytes, or an unreadable head) yields "",
|
|
157
|
+
// falling back to body magic — this can only ADD safety, never reject a real model.
|
|
158
|
+
return stamped === current ? contentType : "";
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** A cheap content fingerprint of a cache file: its size plus a short hash of its
|
|
165
|
+
* sniff head. Distinguishes a re-filled cache slot from the bytes a Content-Type
|
|
166
|
+
* sidecar was written for — even at an identical byte length — without hashing a
|
|
167
|
+
* multi-GB payload in full. "" when the head can't be read (caller treats a
|
|
168
|
+
* mismatch as "don't trust the sidecar"). */
|
|
169
|
+
async function payloadFingerprint(cacheFilePath) {
|
|
170
|
+
const size = await fileSizeOrUndefined(cacheFilePath);
|
|
171
|
+
const head = await readHead(cacheFilePath);
|
|
172
|
+
if (head === undefined)
|
|
173
|
+
return "";
|
|
174
|
+
const headHash = createHash("sha256").update(head).digest("hex").slice(0, 16);
|
|
175
|
+
return `${size ?? ""}:${headHash}`;
|
|
176
|
+
}
|
|
27
177
|
/** On-disk size, or undefined when it can't be determined (fs layer stubbed in
|
|
28
178
|
* tests, or a stat hiccup) — callers must not block a download over a missing
|
|
29
179
|
* number, only over a number that proves corruption. */
|
|
@@ -36,6 +186,331 @@ async function fileSizeOrUndefined(path) {
|
|
|
36
186
|
return undefined;
|
|
37
187
|
}
|
|
38
188
|
}
|
|
189
|
+
/** Destination extensions whose bytes MUST be a binary model payload — never an
|
|
190
|
+
* HTML page or a JSON document. A download that lands under one of these but whose
|
|
191
|
+
* body is actually an auth-challenge / error page is the #473 false-success class:
|
|
192
|
+
* a login HTML or `{"error":...}` JSON saved as `.safetensors`, reported as a green
|
|
193
|
+
* success, then failing at load time ("header too large"). Extensions NOT in this
|
|
194
|
+
* set (e.g. `.json`, `.yaml`, `.txt`, config sidecars) are left unvalidated so a
|
|
195
|
+
* legitimate text/JSON download is never rejected. */
|
|
196
|
+
// Superset of every BINARY model format the repo recognizes as a downloadable
|
|
197
|
+
// weight file — kept in sync with the recognizers in missing-models.ts (MODEL_EXTS)
|
|
198
|
+
// and workflow-converter.ts (the model-file regex). Intentionally EXCLUDES the
|
|
199
|
+
// textual formats those recognizers also allow (`.yaml`, `.json`): those legitimately
|
|
200
|
+
// contain text, so validating them as binary would reject a valid download.
|
|
201
|
+
const MODEL_BINARY_EXTS = new Set([
|
|
202
|
+
".safetensors",
|
|
203
|
+
".safetensor",
|
|
204
|
+
".sft",
|
|
205
|
+
".ckpt",
|
|
206
|
+
".pt",
|
|
207
|
+
".pt2",
|
|
208
|
+
".pth",
|
|
209
|
+
".bin",
|
|
210
|
+
".gguf",
|
|
211
|
+
".onnx",
|
|
212
|
+
".vae",
|
|
213
|
+
".pkl",
|
|
214
|
+
".npz",
|
|
215
|
+
".msgpack",
|
|
216
|
+
]);
|
|
217
|
+
/** How many leading bytes to sniff for a payload's shape. Enough to see an HTML
|
|
218
|
+
* doctype/tag or a JSON error envelope's opening structure without reading a
|
|
219
|
+
* multi-GB model in full. */
|
|
220
|
+
const PAYLOAD_SNIFF_BYTES = 512;
|
|
221
|
+
/** Read the first `n` bytes of a file WITHOUT loading the whole thing (models are
|
|
222
|
+
* multi-GB) — used to sniff a completed download's shape. Best-effort: returns
|
|
223
|
+
* undefined if the file can't be opened/read (never blocks a download over a read
|
|
224
|
+
* hiccup — the size gate has already run). */
|
|
225
|
+
async function readHead(path, n = PAYLOAD_SNIFF_BYTES) {
|
|
226
|
+
let fh;
|
|
227
|
+
try {
|
|
228
|
+
fh = await open(path, "r");
|
|
229
|
+
const buf = Buffer.alloc(n);
|
|
230
|
+
// Loop: a single read() can return fewer bytes than requested even when more
|
|
231
|
+
// are available; fill up to n (or EOF) so a short first read can't truncate the
|
|
232
|
+
// sniff window and hide an HTML/JSON body after byte ~1.
|
|
233
|
+
let off = 0;
|
|
234
|
+
while (off < n) {
|
|
235
|
+
const { bytesRead } = await fh.read(buf, off, n - off, off);
|
|
236
|
+
if (bytesRead === 0)
|
|
237
|
+
break; // EOF
|
|
238
|
+
off += bytesRead;
|
|
239
|
+
}
|
|
240
|
+
return buf.subarray(0, off);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
await fh?.close().catch(() => undefined);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** True when a run of bytes reads as a valid UTF-8 TEXT document (what an HTML page
|
|
250
|
+
* or JSON error IS) rather than binary model bytes. This is a real UTF-8 validation,
|
|
251
|
+
* not "any high byte counts as text": each non-ASCII byte must be a well-formed
|
|
252
|
+
* UTF-8 lead followed by valid continuation bytes, and NUL / C0 control bytes
|
|
253
|
+
* (except the text whitespace controls TAB/LF/VT/FF/CR) hard-fail. Random binary that happens to start with `<`/`{`/`[`
|
|
254
|
+
* almost immediately hits an invalid UTF-8 lead byte (0x80–0xC1 and 0xF5–0xFF — ~26%
|
|
255
|
+
* of all byte values) or a control byte, so it is correctly kept binary; a safetensors
|
|
256
|
+
* whose LE header length is 60 (0x3C='<') is followed by NUL padding and fails at once.
|
|
257
|
+
* A multibyte sequence truncated by the end of the sniff window is accepted (we only
|
|
258
|
+
* sampled a prefix), never treated as invalid. */
|
|
259
|
+
function looksLikeText(slice) {
|
|
260
|
+
const n = slice.length;
|
|
261
|
+
if (n === 0)
|
|
262
|
+
return false;
|
|
263
|
+
let i = 0;
|
|
264
|
+
while (i < n) {
|
|
265
|
+
const b = slice[i];
|
|
266
|
+
if (b < 0x80) {
|
|
267
|
+
// ASCII: printable + the standard text whitespace controls TAB(0x09) LF(0x0a)
|
|
268
|
+
// VT(0x0b) FF(0x0c) CR(0x0d). A form-feed / vertical-tab is valid HTML/XML
|
|
269
|
+
// whitespace, so a real login page like `<html>\f<body>…` MUST still read as
|
|
270
|
+
// text (else it slips through as "binary" and finalizes as a model — #473
|
|
271
|
+
// P0-1). Any OTHER C0 control (NUL etc.) ⇒ not text.
|
|
272
|
+
if ((b >= 0x09 && b <= 0x0d) || (b >= 0x20 && b <= 0x7e)) {
|
|
273
|
+
i += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
// Multibyte lead byte → determine the sequence length; invalid leads reject.
|
|
279
|
+
let len;
|
|
280
|
+
if (b >= 0xc2 && b <= 0xdf)
|
|
281
|
+
len = 2;
|
|
282
|
+
else if (b >= 0xe0 && b <= 0xef)
|
|
283
|
+
len = 3;
|
|
284
|
+
else if (b >= 0xf0 && b <= 0xf4)
|
|
285
|
+
len = 4;
|
|
286
|
+
else
|
|
287
|
+
return false; // 0x80–0xC1 (bare continuation / overlong) or 0xF5–0xFF
|
|
288
|
+
// Strict continuation ranges reject overlong / surrogate / out-of-range forms:
|
|
289
|
+
// E0 → A0..BF (else overlong), ED → 80..9F (else surrogate),
|
|
290
|
+
// F0 → 90..BF (else overlong), F4 → 80..8F (else > U+10FFFF).
|
|
291
|
+
const lo = b === 0xe0 ? 0xa0 : b === 0xf0 ? 0x90 : 0x80;
|
|
292
|
+
const hi = b === 0xed ? 0x9f : b === 0xf4 ? 0x8f : 0xbf;
|
|
293
|
+
// Validate EVERY continuation byte that is PRESENT, even when the sequence is
|
|
294
|
+
// truncated by the sniff window: an already-visible byte that violates its range
|
|
295
|
+
// (e.g. `E0 80` — E0 requires A0..BF) proves the run is NOT valid UTF-8 and must
|
|
296
|
+
// reject, rather than being waved through as "truncated". Only a genuinely
|
|
297
|
+
// cut-off sequence — where we simply ran out of bytes before an INVALID one — is
|
|
298
|
+
// accepted (we only sampled a prefix). This closes a boundary false-positive that
|
|
299
|
+
// would misclassify a raw `.bin` as text/HTML (#473 P0-2 sniff-edge).
|
|
300
|
+
for (let k = 1; k < len; k += 1) {
|
|
301
|
+
if (i + k >= n)
|
|
302
|
+
return true; // ran out of bytes with everything valid so far
|
|
303
|
+
const cont = slice[i + k];
|
|
304
|
+
const clo = k === 1 ? lo : 0x80;
|
|
305
|
+
const chi = k === 1 ? hi : 0xbf;
|
|
306
|
+
if (cont < clo || cont > chi)
|
|
307
|
+
return false; // present-but-invalid continuation
|
|
308
|
+
}
|
|
309
|
+
i += len;
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
/** Classify a sniffed payload head as an HTML page, a JSON document, or opaque
|
|
314
|
+
* binary. Skips a UTF-8 BOM and leading ASCII whitespace, then requires the run
|
|
315
|
+
* that follows to read as TEXT before declaring html/json — so a binary model that
|
|
316
|
+
* merely starts with byte `<`/`{`/`[` (e.g. a safetensors whose LE header length is
|
|
317
|
+
* 60=0x3C or 91=0x5B, followed by NUL padding) is correctly kept as "binary":
|
|
318
|
+
* - leading `<` + text ⇒ "html"
|
|
319
|
+
* - leading `{`/`[` + text ⇒ "json"
|
|
320
|
+
* - anything else ⇒ "binary" (a real model payload). */
|
|
321
|
+
/** Classify an already-DECODED text string (used for UTF-16 bodies): skips leading
|
|
322
|
+
* whitespace, then a `<` ⇒ html / `{`|`[` ⇒ json when the run that follows is clean
|
|
323
|
+
* text (no NUL / C0 control). A binary payload that merely decoded to a leading
|
|
324
|
+
* `<`/`{` (e.g. a safetensors whose UTF-16 decode yields `<` then U+0000) fails the
|
|
325
|
+
* text check and stays binary. */
|
|
326
|
+
function classifyDecodedText(s) {
|
|
327
|
+
let j = 0;
|
|
328
|
+
// Skip leading text whitespace INCLUDING VT/FF (\v \f) — an auth page can begin with
|
|
329
|
+
// a form-feed before the `<`/`{` (`\f<!DOCTYPE html>`); if we stopped at it the char
|
|
330
|
+
// scrutinized would be the control byte, not the tag, and the page would misclassify
|
|
331
|
+
// as binary (#473 P0-1).
|
|
332
|
+
while (j < s.length) {
|
|
333
|
+
const cp = s.charCodeAt(j);
|
|
334
|
+
if (cp === 0x20 || (cp >= 0x09 && cp <= 0x0d))
|
|
335
|
+
j += 1;
|
|
336
|
+
else
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
if (j >= s.length)
|
|
340
|
+
return "binary";
|
|
341
|
+
const ch = s[j];
|
|
342
|
+
// Require the WHOLE decoded run (not just the first 64 chars) to be clean text
|
|
343
|
+
// before declaring html/json: a binary payload that merely decoded to a leading
|
|
344
|
+
// `<`/`{` but then hits control/replacement chars must stay binary (#473 P0-2).
|
|
345
|
+
const rest = s.slice(j);
|
|
346
|
+
const isText = (() => {
|
|
347
|
+
for (const c of rest) {
|
|
348
|
+
const cp = c.codePointAt(0);
|
|
349
|
+
if (cp >= 0x09 && cp <= 0x0d)
|
|
350
|
+
continue; // TAB/LF/VT/FF/CR — text whitespace
|
|
351
|
+
if (cp < 0x20 || cp === 0xfffd)
|
|
352
|
+
return false; // NUL/C0 control or replacement char
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
})();
|
|
356
|
+
if (ch === "<")
|
|
357
|
+
return isText ? "html" : "binary";
|
|
358
|
+
if (ch === "{" || ch === "[")
|
|
359
|
+
return isText ? "json" : "binary";
|
|
360
|
+
return "binary";
|
|
361
|
+
}
|
|
362
|
+
function classifyPayload(buf) {
|
|
363
|
+
// A UTF-16 BOM (FF FE = LE, FE FF = BE) marks a text document — an HTML/JSON auth
|
|
364
|
+
// page can be served UTF-16 (e.g. `text/html; charset=utf-16`, bytes FF FE 3C 00).
|
|
365
|
+
// Decode and classify as text so it isn't mistaken for binary. A binary model that
|
|
366
|
+
// coincidentally starts with a BOM byte pair decodes to control chars → stays binary.
|
|
367
|
+
if (buf.length >= 2 &&
|
|
368
|
+
((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff))) {
|
|
369
|
+
try {
|
|
370
|
+
const label = buf[0] === 0xff ? "utf-16le" : "utf-16be";
|
|
371
|
+
// Trim bytes the sniff window cut mid-code-unit BEFORE decoding, so a truncation
|
|
372
|
+
// artifact doesn't decode to U+FFFD and make a valid UTF-16 page look binary:
|
|
373
|
+
// (1) an odd trailing byte (a half code unit), and (2) a trailing UNPAIRED high
|
|
374
|
+
// surrogate (0xD800–0xDBFF) whose low half fell outside the window — e.g. an
|
|
375
|
+
// emoji split across the 512-byte boundary. A mid-document U+FFFD (genuine
|
|
376
|
+
// binary) is still caught by classifyDecodedText (#473 P0-1 sniff-edge).
|
|
377
|
+
let end = buf.length - (buf.length % 2);
|
|
378
|
+
if (end >= 2) {
|
|
379
|
+
const hi = label === "utf-16le" ? buf[end - 1] : buf[end - 2];
|
|
380
|
+
const loB = label === "utf-16le" ? buf[end - 2] : buf[end - 1];
|
|
381
|
+
const lastUnit = (hi << 8) | loB;
|
|
382
|
+
if (lastUnit >= 0xd800 && lastUnit <= 0xdbff)
|
|
383
|
+
end -= 2; // drop lone high surrogate
|
|
384
|
+
}
|
|
385
|
+
return classifyDecodedText(new TextDecoder(label).decode(buf.subarray(0, end)));
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return "binary";
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
let i = 0;
|
|
392
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf)
|
|
393
|
+
i = 3; // UTF-8 BOM
|
|
394
|
+
// Skip leading text whitespace INCLUDING VT(0x0b)/FF(0x0c) — a login page may open
|
|
395
|
+
// with a form-feed before the `<` (`\f<!DOCTYPE html>`). Stopping at that control
|
|
396
|
+
// byte would scrutinize it instead of the tag and misclassify the page as binary
|
|
397
|
+
// (#473 P0-1).
|
|
398
|
+
while (i < buf.length &&
|
|
399
|
+
(buf[i] === 0x20 || (buf[i] >= 0x09 && buf[i] <= 0x0d))) {
|
|
400
|
+
i += 1;
|
|
401
|
+
}
|
|
402
|
+
if (i >= buf.length)
|
|
403
|
+
return "binary";
|
|
404
|
+
const c = buf[i];
|
|
405
|
+
// Require the WHOLE sniffed run — the entire remaining head, not just the first 64
|
|
406
|
+
// bytes — to read as valid text before declaring html/json. A raw binary whose head
|
|
407
|
+
// merely STARTS with `<`/`{`/`[` but turns to non-text bytes further in (e.g. a .bin
|
|
408
|
+
// that begins `<AAA…` then has NUL/high bytes) must be ACCEPTED as a model, not
|
|
409
|
+
// rejected on a one-byte lead over a tiny window (#473 P0-2). A genuine HTML/JSON
|
|
410
|
+
// auth page is text throughout the sniff window, so it still classifies correctly.
|
|
411
|
+
const rest = buf.subarray(i);
|
|
412
|
+
if (c === 0x3c)
|
|
413
|
+
return looksLikeText(rest) ? "html" : "binary"; // '<'
|
|
414
|
+
if (c === 0x7b || c === 0x5b)
|
|
415
|
+
return looksLikeText(rest) ? "json" : "binary"; // '{' or '['
|
|
416
|
+
return "binary";
|
|
417
|
+
}
|
|
418
|
+
/** Given a completed download's sniffed head + Content-Type, decide whether the
|
|
419
|
+
* bytes are an auth/error page masquerading as a model file (#473). Returns the
|
|
420
|
+
* offending kind, or null when the payload is an acceptable model binary (or the
|
|
421
|
+
* destination isn't a model-binary extension, so this validation doesn't apply).
|
|
422
|
+
*
|
|
423
|
+
* Two independent signals, EITHER of which rejects:
|
|
424
|
+
* 1. Body magic — an HTML page's leading `<` (over a whole-window text run) and a
|
|
425
|
+
* JSON error's leading `{`/`[` are present regardless of how the server labels
|
|
426
|
+
* the response, so body magic catches an auth/error page even when mislabeled
|
|
427
|
+
* `application/octet-stream`. (fetch() transparently decompresses gzip/br, so a
|
|
428
|
+
* compressed error page still sniffs as its decoded HTML/JSON here.)
|
|
429
|
+
* 2. Content-Type — a `text/html`/`application/xhtml` or `application/json` label
|
|
430
|
+
* on a binary-model destination is itself proof of an auth/error document, so it
|
|
431
|
+
* rejects EVEN when the body failed to sniff as text (a stray control byte, an
|
|
432
|
+
* exotic charset, or a truncating proxy — #473 P0-1). Real model hosts never
|
|
433
|
+
* label a weight file text/html or application/json, so this can't reject a
|
|
434
|
+
* legitimate binary (whose Content-Type is octet-stream or similar). */
|
|
435
|
+
function detectNonModelPayload(head, contentType, modelExt) {
|
|
436
|
+
if (!modelExt || !MODEL_BINARY_EXTS.has(modelExt.toLowerCase()))
|
|
437
|
+
return null;
|
|
438
|
+
const kind = head ? classifyPayload(head) : "binary";
|
|
439
|
+
if (kind === "html")
|
|
440
|
+
return "html";
|
|
441
|
+
if (kind === "json")
|
|
442
|
+
return "json";
|
|
443
|
+
// Content-Type is AUTHORITATIVE for the two textual auth/error shapes. A server
|
|
444
|
+
// that LABELS a response `text/html`/`application/xhtml` or `application/json` for
|
|
445
|
+
// a binary-model destination is serving a login/auth or API-error document, never
|
|
446
|
+
// a weight file — so reject it EVEN when the body itself failed to sniff as text
|
|
447
|
+
// (a stray control byte, an exotic charset, or a truncating proxy could otherwise
|
|
448
|
+
// let a genuine HTML/JSON page slip through as "binary" — #473 P0-1). Real model
|
|
449
|
+
// hosts serve weights as application/octet-stream (or similar), never as text/html
|
|
450
|
+
// or application/json, so this can't reject a legitimate binary. (The body-magic
|
|
451
|
+
// checks above already caught the common octet-stream-mislabeled auth page.)
|
|
452
|
+
if (/text\/html|application\/xhtml/i.test(contentType))
|
|
453
|
+
return "html";
|
|
454
|
+
if (/(^|[/+])json\b/i.test(contentType))
|
|
455
|
+
return "json";
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
/** Actionable error for a rejected auth/error payload (#473). Names the concrete
|
|
459
|
+
* failure (an HTML page / JSON error saved as a model) and, for CivitAI, the exact
|
|
460
|
+
* fix (set/refresh the API token) — because a 200-with-auth-challenge is precisely
|
|
461
|
+
* what CivitAI returns for a missing/invalid key or a gated model, and Manager /
|
|
462
|
+
* a bare fetch would otherwise save that page under the `.safetensors` name. */
|
|
463
|
+
function nonModelPayloadError(kind, url, logUrl) {
|
|
464
|
+
let host = "";
|
|
465
|
+
try {
|
|
466
|
+
host = new URL(url).hostname;
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
/* logUrl is used for reporting; host is only for the CivitAI hint */
|
|
470
|
+
}
|
|
471
|
+
const isCivitai = /(^|\.)civitai\.com$/i.test(host);
|
|
472
|
+
const what = kind === "html"
|
|
473
|
+
? "an HTML page (an authentication/login or error page)"
|
|
474
|
+
: "a JSON document (an API error or auth challenge)";
|
|
475
|
+
const civitaiHint = isCivitai
|
|
476
|
+
? " CivitAI returns an auth challenge like this when no valid token is sent — set or refresh " +
|
|
477
|
+
"CIVITAI_API_TOKEN (panel Settings › “Set CivitAI token…”, or the env var; create one at " +
|
|
478
|
+
"civitai.com/user/account) and retry. Note: on a REMOTE ComfyUI target the token is NOT " +
|
|
479
|
+
"forwarded to ComfyUI-Manager, so a locally-set token does not fix a remote Manager download."
|
|
480
|
+
: "";
|
|
481
|
+
return new ModelError(`Download rejected: the server returned ${what}, not a model file, but the destination is a ` +
|
|
482
|
+
`binary model file. Refusing to save it as a model (it would land as a corrupt file under a ` +
|
|
483
|
+
`false success and fail at load time, e.g. "header too large").${civitaiHint}`, { url: logUrl });
|
|
484
|
+
}
|
|
485
|
+
/** Validate that a COMPLETED file at `targetPath` is a real model payload — not an
|
|
486
|
+
* HTML/JSON auth/error page — when the destination is a binary-model extension
|
|
487
|
+
* (#473). FAILS CLOSED: an offending payload OR an unreadable-for-sniffing file (we
|
|
488
|
+
* can't confirm it) throws, after `onReject` cleans up, so a corrupt file is never
|
|
489
|
+
* finalized under a success. A no-op for non-model destinations. `contentType` is a
|
|
490
|
+
* secondary signal (empty on cache-hit/cloud paths, where only the bytes are known). */
|
|
491
|
+
async function assertModelPayloadOrThrow(opts) {
|
|
492
|
+
const { targetPath, modelExt, contentType, url, logUrl, failClosed = true } = opts;
|
|
493
|
+
if (!modelExt || !MODEL_BINARY_EXTS.has(modelExt.toLowerCase()))
|
|
494
|
+
return;
|
|
495
|
+
const head = await readHead(targetPath);
|
|
496
|
+
// An unreadable OR unexpectedly-empty head (a non-empty file that read 0 bytes
|
|
497
|
+
// under an fs race) leaves the payload UNVERIFIED. On the fail-closed paths refuse
|
|
498
|
+
// to finalize it rather than trust it (#473/#467); on the best-effort cloud path,
|
|
499
|
+
// skip. (A genuinely 0-byte download is already rejected upstream by assertComplete
|
|
500
|
+
// / the cache-hit 0-byte guard.)
|
|
501
|
+
if (head === undefined || head.length === 0) {
|
|
502
|
+
if (!failClosed)
|
|
503
|
+
return;
|
|
504
|
+
await opts.onReject?.();
|
|
505
|
+
throw new ModelError(`Download could not be verified: the completed file could not be read to confirm it is a ` +
|
|
506
|
+
`model payload (and not an HTML/JSON auth/error page). Not finalizing it as a model; retry.`, { url: logUrl });
|
|
507
|
+
}
|
|
508
|
+
const kind = detectNonModelPayload(head, contentType, modelExt);
|
|
509
|
+
if (!kind)
|
|
510
|
+
return;
|
|
511
|
+
await opts.onReject?.();
|
|
512
|
+
throw nonModelPayloadError(kind, url, logUrl);
|
|
513
|
+
}
|
|
39
514
|
/** POSITIVELY true only when `path` is confirmed discarded — it no longer exists
|
|
40
515
|
* (stat throws ENOENT) or exists but is empty (size 0). A non-ENOENT stat error
|
|
41
516
|
* (a transient fs hiccup) or a still-present non-empty file returns false, so a
|
|
@@ -228,7 +703,13 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
|
|
|
228
703
|
/** Sink for THIS physical download's resume decision, threaded from the job so
|
|
229
704
|
* the outcome is stored on that job — never in a shared keyed map that could
|
|
230
705
|
* misattribute it to another job (#467). Absent for internal/direct callers. */
|
|
231
|
-
onResume
|
|
706
|
+
onResume,
|
|
707
|
+
/** Extension of the FINAL destination (e.g. ".safetensors"), NOT of the .partial
|
|
708
|
+
* we stream into. When it's a binary-model extension the completed payload is
|
|
709
|
+
* validated as a real model — an HTML/JSON auth/error body is rejected rather
|
|
710
|
+
* than finalized as a corrupt model under a false success (#473). Empty ⇒ no
|
|
711
|
+
* payload validation (unknown/non-model destination). */
|
|
712
|
+
modelExt = "") {
|
|
232
713
|
if (supportsCloudDownload(url)) {
|
|
233
714
|
// Cloud downloaders (S3/Azure) don't range-resume — they overwrite the target.
|
|
234
715
|
// If a partial exists it's being discarded; surface that (#467) instead of a
|
|
@@ -259,7 +740,26 @@ onResume) {
|
|
|
259
740
|
await safeRm(targetPath);
|
|
260
741
|
throw new ModelError("Download produced a 0-byte file — the cloud source (S3/Azure) sent no data. Removed it; retry.", { url: logUrl });
|
|
261
742
|
}
|
|
262
|
-
|
|
743
|
+
// A cloud (S3/Azure) object can also be an error document — e.g. an XML
|
|
744
|
+
// AccessDenied body saved under a `.safetensors` name. No HTTP Content-Type is
|
|
745
|
+
// available here, so sniff the body alone (an XML/HTML error starts with `<`).
|
|
746
|
+
await assertModelPayloadOrThrow({
|
|
747
|
+
targetPath,
|
|
748
|
+
modelExt,
|
|
749
|
+
contentType: "",
|
|
750
|
+
url,
|
|
751
|
+
logUrl,
|
|
752
|
+
onReject: async () => {
|
|
753
|
+
await discardRejectedPayload(targetPath, undefined, logUrl);
|
|
754
|
+
},
|
|
755
|
+
// FAIL CLOSED, same as the HTTP path: a cloud (S3/Azure) object can be an XML/
|
|
756
|
+
// HTML AccessDenied body saved under a `.safetensors` name, and if we CAN'T
|
|
757
|
+
// sniff the completed file (a transient open/read failure) we must NOT finalize
|
|
758
|
+
// it — cannot verify ⇒ do not finalize (#473 P0-3). Previously this was a
|
|
759
|
+
// best-effort skip, which let an unsniffable auth/error body get renamed in.
|
|
760
|
+
failClosed: true,
|
|
761
|
+
});
|
|
762
|
+
return ""; // cloud (S3/Azure) has no HTTP Content-Type to persist
|
|
263
763
|
}
|
|
264
764
|
// Resumable downloads: when a partial file exists at targetPath we ask the
|
|
265
765
|
// server for the remaining bytes via Range. If the server returns 206 with
|
|
@@ -703,14 +1203,45 @@ onResume) {
|
|
|
703
1203
|
`the bad file; retry.`, { url: logUrl });
|
|
704
1204
|
}
|
|
705
1205
|
};
|
|
1206
|
+
// Reject a completed body that is an HTML/JSON auth/error page rather than a
|
|
1207
|
+
// model file (#473). Runs AFTER assertComplete (size is right) but BEFORE the
|
|
1208
|
+
// .partial is renamed into place / the sidecar is dropped — a plausibly-sized
|
|
1209
|
+
// login page or `{"error":...}` would otherwise pass the size gate and finalize
|
|
1210
|
+
// as a corrupt model under a green success. Removes the bad partial + sidecar so
|
|
1211
|
+
// a retry starts clean, and surfaces an actionable (CivitAI-aware) error.
|
|
1212
|
+
// The response Content-Type — used now for the stream-time check AND returned to
|
|
1213
|
+
// the cache layer so it can persist it beside the cache file. A later cache-HIT /
|
|
1214
|
+
// coalesced caller re-validates with contentType "" (the header is long gone), so
|
|
1215
|
+
// without persisting it a body that only a Content-Type could flag (e.g. an
|
|
1216
|
+
// HTML/JSON page whose bytes don't sniff as text) could slip through on reuse (#473).
|
|
1217
|
+
const responseContentType = res.headers.get("content-type") || "";
|
|
1218
|
+
const assertModelPayload = () => assertModelPayloadOrThrow({
|
|
1219
|
+
targetPath,
|
|
1220
|
+
modelExt,
|
|
1221
|
+
contentType: responseContentType,
|
|
1222
|
+
url,
|
|
1223
|
+
logUrl,
|
|
1224
|
+
// Remove the rejected partial + its resume sidecar, and if removal fails,
|
|
1225
|
+
// NEUTRALIZE the partial (truncate to 0) so a retry can't resume onto the
|
|
1226
|
+
// poisoned HTML/JSON prefix (#473 P1). safeRm alone would swallow an EPERM and
|
|
1227
|
+
// leave a resumable poisoned partial behind. When resumable, ALSO drop a poison
|
|
1228
|
+
// marker so a content-type-only rejection (whose body may sniff as binary on
|
|
1229
|
+
// retry) still can't be resumed even if neutralization failed.
|
|
1230
|
+
onReject: async () => {
|
|
1231
|
+
await discardRejectedPayload(targetPath, resumable ? validatorSidecar : undefined, logUrl);
|
|
1232
|
+
if (resumable)
|
|
1233
|
+
await writePoisonMarker(`${targetPath}.rejected`, logUrl);
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
706
1236
|
// No progress wanted (internal/cache caller, or not under the panel) → straight pipe.
|
|
707
1237
|
if (!progress) {
|
|
708
1238
|
await pipeline(nodeStream, fileStream);
|
|
709
1239
|
await assertComplete();
|
|
1240
|
+
await assertModelPayload();
|
|
710
1241
|
// Complete: the validator sidecar is only needed to guard a resume, so drop it.
|
|
711
1242
|
if (resumable)
|
|
712
1243
|
await safeRm(validatorSidecar);
|
|
713
|
-
return;
|
|
1244
|
+
return responseContentType;
|
|
714
1245
|
}
|
|
715
1246
|
// Tally bytes as they flow and report throughput to the panel tray.
|
|
716
1247
|
const total = expectedTotal;
|
|
@@ -737,17 +1268,19 @@ onResume) {
|
|
|
737
1268
|
try {
|
|
738
1269
|
await pipeline(nodeStream, counter, fileStream);
|
|
739
1270
|
await assertComplete();
|
|
1271
|
+
await assertModelPayload();
|
|
740
1272
|
if (resumable)
|
|
741
1273
|
await safeRm(validatorSidecar);
|
|
742
1274
|
bytesPerSec = 0;
|
|
743
1275
|
emit("done", true);
|
|
1276
|
+
return responseContentType;
|
|
744
1277
|
}
|
|
745
1278
|
catch (err) {
|
|
746
1279
|
emit("error", true);
|
|
747
1280
|
throw err;
|
|
748
1281
|
}
|
|
749
1282
|
}
|
|
750
|
-
async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progress, onResume) {
|
|
1283
|
+
async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progress, onResume, modelExt = "") {
|
|
751
1284
|
// Representation-aware identity (#467): a same-URL download with different HTTP
|
|
752
1285
|
// auth headers OR different cloud (S3/Azure) credentials gets its OWN cache file,
|
|
753
1286
|
// partial and in-flight slot — never coalesced onto another caller's stream.
|
|
@@ -771,6 +1304,8 @@ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progres
|
|
|
771
1304
|
// materializing an empty file that reports success.
|
|
772
1305
|
if (info.size === 0) {
|
|
773
1306
|
await downloadCacheFs.rm(target, { force: true }).catch(() => undefined);
|
|
1307
|
+
// Drop its stale Content-Type sidecar too — it now describes nothing.
|
|
1308
|
+
await downloadCacheFs.rm(cacheCtSidecar(target), { force: true }).catch(() => undefined);
|
|
774
1309
|
}
|
|
775
1310
|
else {
|
|
776
1311
|
await touch(target);
|
|
@@ -788,6 +1323,7 @@ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progres
|
|
|
788
1323
|
// restarting from zero. (See streamUrlToFile for the Range + flags
|
|
789
1324
|
// handshake.) Cleanup on terminal failure stays unchanged.
|
|
790
1325
|
const partial = join(cacheDir(), `.${basename(target)}.partial`);
|
|
1326
|
+
const rejectedMarker = `${partial}.rejected`;
|
|
791
1327
|
let resumeFromBytes = 0;
|
|
792
1328
|
try {
|
|
793
1329
|
const existing = await downloadCacheFs.stat(partial);
|
|
@@ -802,11 +1338,63 @@ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progres
|
|
|
802
1338
|
catch {
|
|
803
1339
|
// No partial — fresh download.
|
|
804
1340
|
}
|
|
1341
|
+
// #473 P1 — poison MARKER guard (cleanup- AND content-type-independent). A prior
|
|
1342
|
+
// attempt that REJECTED this download (even solely on the response Content-Type,
|
|
1343
|
+
// which is gone now) drops a `.rejected` marker next to the partial. If it's
|
|
1344
|
+
// present, the leftover partial is poison regardless of what its bytes sniff as —
|
|
1345
|
+
// never resume onto it. Discard everything and restart from 0.
|
|
1346
|
+
let markerPresent = false;
|
|
805
1347
|
try {
|
|
806
|
-
|
|
807
|
-
|
|
1348
|
+
markerPresent = (await downloadCacheFs.stat(rejectedMarker)).isFile();
|
|
1349
|
+
}
|
|
1350
|
+
catch {
|
|
1351
|
+
/* no marker */
|
|
1352
|
+
}
|
|
1353
|
+
if (markerPresent) {
|
|
1354
|
+
if (resumeFromBytes > 0) {
|
|
1355
|
+
logger.warn(`Discarding a previously-rejected (${resumeFromBytes}-byte) partial before resume: a ` +
|
|
1356
|
+
`poison marker from an earlier non-model rejection is present — restarting from 0 (#473).`, { url: logUrl, bytes: resumeFromBytes });
|
|
1357
|
+
}
|
|
1358
|
+
const neutralized = await discardRejectedPayload(partial, `${partial}.etag`, logUrl ?? redactUrlForLogs(url));
|
|
1359
|
+
// Keep the marker if the partial could NOT be neutralized (rm AND truncate both
|
|
1360
|
+
// failed), so a still-poisoned leftover stays flagged for the next attempt.
|
|
1361
|
+
// resumeFromBytes = 0 forces this attempt to re-download fresh ("w" truncates the
|
|
1362
|
+
// leftover), so the poison is cleared here regardless.
|
|
1363
|
+
if (neutralized)
|
|
1364
|
+
await safeRm(rejectedMarker);
|
|
1365
|
+
resumeFromBytes = 0;
|
|
1366
|
+
}
|
|
1367
|
+
// #473 P1 — cleanup-INDEPENDENT poison guard (body-magic). A prior attempt may have REJECTED
|
|
1368
|
+
// this download as an HTML/JSON auth/error body and then been UNABLE to remove or
|
|
1369
|
+
// truncate the leftover .partial (a denied rm AND a denied truncate). Re-inspect
|
|
1370
|
+
// the partial's HEAD here, before deciding to resume: if it is itself a non-model
|
|
1371
|
+
// (HTML/JSON) body for a binary-model destination, it is poison — NEVER resume
|
|
1372
|
+
// onto it. Reset to a fresh download (resumeFromBytes = 0 ⇒ no Range ⇒ the "w"
|
|
1373
|
+
// open truncates the poisoned bytes) and best-effort discard the sidecar, so the
|
|
1374
|
+
// invariant "a rejected leftover can't be treated as resumable" holds even when
|
|
1375
|
+
// both cleanup mechanisms failed. A legitimate in-progress partial sniffs as
|
|
1376
|
+
// binary (null) and resumes normally.
|
|
1377
|
+
if (resumeFromBytes > 0 && modelExt) {
|
|
1378
|
+
const partialHead = await readHead(partial);
|
|
1379
|
+
if (detectNonModelPayload(partialHead, "", modelExt)) {
|
|
1380
|
+
logger.warn(`Discarding a previously-rejected non-model (${resumeFromBytes}-byte) partial before ` +
|
|
1381
|
+
`resume: its head is an HTML/JSON auth/error body, not a model — restarting from 0 so ` +
|
|
1382
|
+
`the poisoned bytes can't be resumed onto (#473).`, { url: logUrl, bytes: resumeFromBytes });
|
|
1383
|
+
await discardRejectedPayload(partial, `${partial}.etag`, logUrl ?? redactUrlForLogs(url));
|
|
1384
|
+
resumeFromBytes = 0;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
try {
|
|
1388
|
+
const contentType = await streamUrlToFile(url, partial, headers, logUrl, storageAuth, resumeFromBytes, progress, true, // resumable: cache partials use the .partial + If-Range resume handshake
|
|
1389
|
+
onResume, modelExt);
|
|
808
1390
|
await downloadCacheFs.rename(partial, target);
|
|
809
1391
|
await touch(target);
|
|
1392
|
+
// Persist the response Content-Type beside the cache file so a later cache-HIT /
|
|
1393
|
+
// coalesced caller re-validates with it (#473 reuse gap).
|
|
1394
|
+
await writeCacheContentType(target, contentType);
|
|
1395
|
+
// Clean any stale poison marker now that a CLEAN payload finalized under this
|
|
1396
|
+
// key — the partial is gone (renamed) and the bytes passed validation.
|
|
1397
|
+
await safeRm(rejectedMarker);
|
|
810
1398
|
return target;
|
|
811
1399
|
}
|
|
812
1400
|
catch (err) {
|
|
@@ -1000,18 +1588,57 @@ async function evictLruIfNeeded() {
|
|
|
1000
1588
|
files.sort((a, b) => a.time - b.time);
|
|
1001
1589
|
for (const file of files) {
|
|
1002
1590
|
await downloadCacheFs.rm(file.path, { force: true });
|
|
1591
|
+
// Evict the Content-Type sidecar with its cache file so it can't outlive it.
|
|
1592
|
+
await downloadCacheFs.rm(cacheCtSidecar(file.path), { force: true }).catch(() => undefined);
|
|
1003
1593
|
total -= file.size;
|
|
1004
1594
|
if (total <= limit)
|
|
1005
1595
|
break;
|
|
1006
1596
|
}
|
|
1007
1597
|
}
|
|
1008
|
-
export async function downloadUrlToFile(url, targetPath, headers, logUrl, storageAuth = {}, progress) {
|
|
1009
|
-
await streamUrlToFile(url, targetPath, headers, logUrl, storageAuth, 0, progress);
|
|
1598
|
+
export async function downloadUrlToFile(url, targetPath, headers, logUrl, storageAuth = {}, progress, modelExt = extname(targetPath)) {
|
|
1599
|
+
await streamUrlToFile(url, targetPath, headers, logUrl, storageAuth, 0, progress, false, undefined, modelExt);
|
|
1010
1600
|
}
|
|
1011
1601
|
export async function downloadWithCache(options) {
|
|
1012
1602
|
const logUrl = options.logUrl ?? redactUrlForLogs(options.url);
|
|
1603
|
+
// The FINAL destination extension drives model-payload validation (#473). Derived
|
|
1604
|
+
// from the real targetPath here — NOT from the .partial / random temp we actually
|
|
1605
|
+
// stream into (those carry `.partial`/`.tmp`) — and threaded down so the completed
|
|
1606
|
+
// body is checked against what the destination IS (a `.safetensors` etc.).
|
|
1607
|
+
const modelExt = extname(options.targetPath);
|
|
1013
1608
|
try {
|
|
1014
|
-
const cachePath = await downloadIntoCache(options.url, options.headers, logUrl, options.storageAuth, options.progress, options.onResume);
|
|
1609
|
+
const cachePath = await downloadIntoCache(options.url, options.headers, logUrl, options.storageAuth, options.progress, options.onResume, modelExt);
|
|
1610
|
+
// Validate the CACHE FILE itself before materializing it to this destination —
|
|
1611
|
+
// this is the authoritative per-caller gate (#473). It covers four cases the
|
|
1612
|
+
// stream-time check can't: (1) a CACHE HIT that skipped streaming entirely,
|
|
1613
|
+
// (2) a COALESCED caller whose .safetensors destination consumed a stream that
|
|
1614
|
+
// ran under a different (e.g. non-model) caller's modelExt, (3) a legacy cache
|
|
1615
|
+
// entry poisoned before this fix, and (4) a body that ONLY the Content-Type could
|
|
1616
|
+
// flag (its bytes don't sniff as text). The live Content-Type header is gone by
|
|
1617
|
+
// now, so we recover it from the `.ct` sidecar persisted at download time —
|
|
1618
|
+
// otherwise a Content-Type-only-flaggable body could finalize as a model on reuse.
|
|
1619
|
+
const cachedContentType = await readCacheContentType(cachePath);
|
|
1620
|
+
await assertModelPayloadOrThrow({
|
|
1621
|
+
targetPath: cachePath,
|
|
1622
|
+
modelExt,
|
|
1623
|
+
contentType: cachedContentType,
|
|
1624
|
+
url: options.url,
|
|
1625
|
+
logUrl,
|
|
1626
|
+
// Drop the poisoned cache entry (+ its Content-Type sidecar) so a retry
|
|
1627
|
+
// re-downloads clean rather than re-serving the same bad bytes on every call.
|
|
1628
|
+
// Truncate-to-0 fallback if rm fails (a 0-byte cache entry is treated as a
|
|
1629
|
+
// miss), and log if even that fails, so a poisoned cache entry can't silently
|
|
1630
|
+
// re-poison cache hits (#473 P1).
|
|
1631
|
+
onReject: async () => {
|
|
1632
|
+
const neutralized = await discardRejectedPayload(cachePath, undefined, logUrl);
|
|
1633
|
+
// Only drop the Content-Type sidecar once the poisoned cache file is actually
|
|
1634
|
+
// gone/emptied. If the cache file SURVIVED (rm AND truncate both failed), KEEP
|
|
1635
|
+
// the `.ct` so the NEXT caller can still reject it by its persisted
|
|
1636
|
+
// Content-Type — deleting the evidence would let a Content-Type-only poison be
|
|
1637
|
+
// re-served as a model on a later cache hit (#473 P1).
|
|
1638
|
+
if (neutralized)
|
|
1639
|
+
await safeRm(cacheCtSidecar(cachePath));
|
|
1640
|
+
},
|
|
1641
|
+
});
|
|
1015
1642
|
const materializedBy = await materializeCacheFile(cachePath, options.targetPath);
|
|
1016
1643
|
await evictLruIfNeeded();
|
|
1017
1644
|
return {
|
|
@@ -1038,7 +1665,7 @@ export async function downloadWithCache(options) {
|
|
|
1038
1665
|
// leaves the destination untouched.
|
|
1039
1666
|
const tmp = await reserveExclusiveTemp(options.targetPath, "dl");
|
|
1040
1667
|
try {
|
|
1041
|
-
await downloadUrlToFile(options.url, tmp, options.headers, logUrl, options.storageAuth, options.progress);
|
|
1668
|
+
await downloadUrlToFile(options.url, tmp, options.headers, logUrl, options.storageAuth, options.progress, modelExt);
|
|
1042
1669
|
await renameTempOverDestination(tmp, options.targetPath);
|
|
1043
1670
|
}
|
|
1044
1671
|
catch (e) {
|