|
1 | 1 | import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"; |
2 | 2 | import { mkdir, writeFile } from "node:fs/promises"; |
3 | | -import type { IncomingMessage } from "node:http"; |
4 | | -import https from "node:https"; |
5 | | -import type { RequestOptions } from "node:https"; |
6 | 3 | import { basename, join, resolve, sep } from "node:path"; |
7 | 4 | import { getConfigDir } from "../config"; |
8 | 5 | import { assessUrlDestination, resolvePublicAddresses } from "../lib/destination-policy"; |
9 | 6 | import { recordOwnedConfigPath } from "../lib/config-ownership"; |
| 7 | +import { pinnedHttpGet, type PinnedAddress } from "../lib/pinned-http"; |
| 8 | + |
| 9 | +export type { PinnedAddress } from "../lib/pinned-http"; |
10 | 10 |
|
11 | 11 | const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024; |
12 | 12 | const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; |
@@ -40,8 +40,6 @@ export interface ImageBudget { |
40 | 40 | spent: number; |
41 | 41 | } |
42 | 42 |
|
43 | | -export type PinnedAddress = { address: string; family: number }; |
44 | | - |
45 | 43 | /** Test seam / custom transport: must connect to `pinned`, not re-resolve `url`'s hostname. */ |
46 | 44 | export type PinnedDownloadFn = ( |
47 | 45 | url: string, |
@@ -282,120 +280,14 @@ export function pinnedHttpsGet( |
282 | 280 | } |
283 | 281 | const maxBytes = options?.maxBytes ?? MAX_DOWNLOAD_BYTES; |
284 | 282 | const idleTimeoutMs = options?.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS; |
285 | | - |
286 | | - return new Promise<Response>((resolve, reject) => { |
287 | | - if (signal?.aborted) { |
288 | | - reject(signal.reason instanceof Error ? signal.reason : new Error("aborted")); |
289 | | - return; |
290 | | - } |
291 | | - |
292 | | - let settled = false; |
293 | | - const fail = (err: unknown) => { |
294 | | - try { req.destroy(); } catch { /* ignore */ } |
295 | | - if (settled) return; |
296 | | - settled = true; |
297 | | - reject(err instanceof Error ? err : new Error(String(err))); |
298 | | - }; |
299 | | - |
300 | | - const optionsHttps: RequestOptions & { servername?: string } = { |
301 | | - protocol: "https:", |
302 | | - hostname: parsed.hostname, |
303 | | - servername: parsed.hostname, |
304 | | - port: parsed.port || 443, |
305 | | - path: `${parsed.pathname}${parsed.search}`, |
306 | | - method: "GET", |
307 | | - headers: { Host: parsed.host }, |
308 | | - rejectUnauthorized: options?.rejectUnauthorized, |
309 | | - lookup(_hostname, lookupOptions, callback) { |
310 | | - const opts = typeof lookupOptions === "function" ? undefined : lookupOptions; |
311 | | - const cb = typeof lookupOptions === "function" ? lookupOptions : callback; |
312 | | - if (!cb) return; |
313 | | - // Pin the validated peer — do not call dns.lookup again. |
314 | | - // Honor `{ all: true }` array shape used by some Node/Bun https paths. |
315 | | - if (opts && typeof opts === "object" && "all" in opts && opts.all) { |
316 | | - (cb as (err: NodeJS.ErrnoException | null, addresses: PinnedAddress[]) => void)( |
317 | | - null, |
318 | | - [{ address: pinned.address, family: pinned.family }], |
319 | | - ); |
320 | | - return; |
321 | | - } |
322 | | - (cb as (err: NodeJS.ErrnoException | null, address: string, family: 4 | 6) => void)( |
323 | | - null, |
324 | | - pinned.address, |
325 | | - pinned.family as 4 | 6, |
326 | | - ); |
327 | | - }, |
328 | | - }; |
329 | | - |
330 | | - const req = https.request(optionsHttps, (res: IncomingMessage) => { |
331 | | - const status = res.statusCode ?? 0; |
332 | | - // Any non-2xx must destroy immediately. Returning a streaming Response for |
333 | | - // 4xx/5xx (or 3xx) lets callers that only check `Response.ok` abandon an |
334 | | - // unread body while the peer keeps sending — a failed-response socket leak. |
335 | | - if (status < 200 || status >= 300) { |
336 | | - try { res.destroy(); } catch { /* ignore */ } |
337 | | - fail(new Error("image download failed: " + status)); |
338 | | - return; |
339 | | - } |
340 | | - const headers = new Headers(); |
341 | | - for (const [key, value] of Object.entries(res.headers)) { |
342 | | - if (value === undefined || value === null) continue; |
343 | | - if (Array.isArray(value)) { |
344 | | - for (const item of value) headers.append(key, String(item)); |
345 | | - } else { |
346 | | - headers.set(key, String(value)); |
347 | | - } |
348 | | - } |
349 | | - |
350 | | - let received = 0; |
351 | | - const stream = new ReadableStream<Uint8Array>({ |
352 | | - start(controller) { |
353 | | - res.setTimeout(idleTimeoutMs, () => { |
354 | | - fail(new Error("image download stalled")); |
355 | | - try { controller.error(new Error("image download stalled")); } catch { /* closed */ } |
356 | | - }); |
357 | | - res.on("data", (chunk: Buffer | string) => { |
358 | | - const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk; |
359 | | - received += buf.byteLength; |
360 | | - if (received > maxBytes) { |
361 | | - const err = new Error(`image download exceeds ${maxBytes} byte cap`); |
362 | | - fail(err); |
363 | | - try { controller.error(err); } catch { /* closed */ } |
364 | | - return; |
365 | | - } |
366 | | - try { controller.enqueue(buf); } catch { /* closed */ } |
367 | | - }); |
368 | | - res.on("end", () => { |
369 | | - try { controller.close(); } catch { /* closed */ } |
370 | | - }); |
371 | | - res.on("error", (err: Error) => { |
372 | | - fail(err); |
373 | | - try { controller.error(err); } catch { /* closed */ } |
374 | | - }); |
375 | | - }, |
376 | | - cancel() { |
377 | | - req.destroy(); |
378 | | - }, |
379 | | - }); |
380 | | - |
381 | | - if (settled) return; |
382 | | - settled = true; |
383 | | - resolve(new Response(stream, { status, headers })); |
384 | | - }); |
385 | | - |
386 | | - const onAbort = () => { |
387 | | - fail(signal?.reason instanceof Error ? signal.reason : new Error("aborted")); |
388 | | - }; |
389 | | - signal?.addEventListener("abort", onAbort, { once: true }); |
390 | | - req.setTimeout(idleTimeoutMs, () => { |
391 | | - fail(new Error("image download timed out")); |
392 | | - }); |
393 | | - req.on("error", (err) => { |
394 | | - signal?.removeEventListener("abort", onAbort); |
395 | | - fail(err); |
396 | | - }); |
397 | | - req.on("close", () => signal?.removeEventListener("abort", onAbort)); |
398 | | - req.end(); |
| 283 | + return pinnedHttpGet(url, pinned, signal, { |
| 284 | + maxBytes, |
| 285 | + idleTimeoutMs, |
| 286 | + rejectUnauthorized: options?.rejectUnauthorized, |
| 287 | + context: "image download", |
| 288 | + }).then(response => { |
| 289 | + if (!response.ok) throw new Error("image download failed: " + response.status); |
| 290 | + return response; |
399 | 291 | }); |
400 | 292 | } |
401 | 293 |
|
|
0 commit comments