Files
hyperframes/packages/engine/src/utils/urlDownloader.ts
T

403 lines
12 KiB
TypeScript

import {
closeSync,
createWriteStream,
existsSync,
fsyncSync,
mkdtempSync,
mkdirSync,
lstatSync,
openSync,
renameSync,
rmSync,
statSync,
} from "fs";
import { createHash } from "crypto";
import { BlockList, isIP } from "node:net";
import { dirname, extname, join } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
const inFlightDownloads = new Map<string, Promise<string>>();
const signalScopes = new WeakMap<AbortSignal, number>();
let nextSignalScope = 1;
function signalScopeKey(signal: AbortSignal | undefined): string {
if (!signal) return "none";
let scope = signalScopes.get(signal);
if (scope === undefined) {
scope = nextSignalScope;
nextSignalScope += 1;
signalScopes.set(signal, scope);
}
return String(scope);
}
export type UrlDownloadFailureKind =
| "cancelled"
| "timeout"
| "http_not_found"
| "http_rejected"
| "http_transient"
| "network"
| "empty_body"
| "filesystem";
export class UrlDownloadError extends Error {
constructor(
readonly kind: UrlDownloadFailureKind,
readonly retryable: boolean,
message: string,
readonly status?: number,
) {
super(message);
this.name = "UrlDownloadError";
}
}
function classifyHttpFailure(status: number, statusText: string): UrlDownloadError {
const message = `HTTP ${status}: ${statusText}`;
if (status === 404 || status === 410) {
return new UrlDownloadError("http_not_found", false, message, status);
}
if (status === 408 || status === 429 || status >= 500) {
return new UrlDownloadError("http_transient", true, message, status);
}
return new UrlDownloadError("http_rejected", false, message, status);
}
function classifyDownloadFailure(error: unknown): UrlDownloadError {
if (error instanceof UrlDownloadError) return error;
const message = error instanceof Error ? error.message : String(error);
let current: unknown = error;
// Undici often wraps a mid-body socket failure as `TypeError: terminated`
// with the actionable `UND_ERR_*` code on `cause`.
for (let depth = 0; current && depth < 4; depth += 1) {
if (isRetryableNetworkCause(current)) {
return new UrlDownloadError("network", true, `Download failed: ${message}`);
}
current =
typeof current === "object" && current !== null && "cause" in current
? current.cause
: undefined;
}
return new UrlDownloadError("filesystem", false, `Download failed: ${message}`);
}
const RETRYABLE_NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"]);
function isRetryableNetworkCause(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const code =
typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
? error.code
: "";
return (
RETRYABLE_NETWORK_CODES.has(code) ||
code.startsWith("UND_ERR_") ||
/fetch failed|network|socket|connection reset|terminated/i.test(message)
);
}
const NON_PUBLIC_IPV4_ADDRESSES = new BlockList();
for (const [network, prefix] of [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.0.0.0", 24],
["192.0.2.0", 24],
["192.88.99.0", 24],
["192.168.0.0", 16],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4],
["240.0.0.0", 4],
] as const) {
NON_PUBLIC_IPV4_ADDRESSES.addSubnet(network, prefix, "ipv4");
}
const NON_PUBLIC_IPV6_ADDRESSES = new BlockList();
for (const [network, prefix] of [
["::", 128],
["::1", 128],
["::ffff:0:0", 96],
["fc00::", 7],
["fe80::", 10],
["fec0::", 10],
["ff00::", 8],
["2001:db8::", 32],
] as const) {
NON_PUBLIC_IPV6_ADDRESSES.addSubnet(network, prefix, "ipv6");
}
function isBlockedHost(hostname: string): boolean {
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (h === "localhost") return true;
const addressType = isIP(h);
if (addressType === 0) return false;
return addressType === 4
? NON_PUBLIC_IPV4_ADDRESSES.check(h, "ipv4")
: NON_PUBLIC_IPV6_ADDRESSES.check(h, "ipv6");
}
/**
* Validate that a URL is safe to fetch on behalf of customer-supplied
* compositions. Throws if the URL is non-HTTPS or targets a private/reserved
* address range (SSRF guard).
*/
export function assertPublicHttpsUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`[URLDownloader] Invalid URL: ${url}`);
}
if (parsed.protocol !== "https:") {
throw new Error(
`[URLDownloader] Only HTTPS URLs are permitted in compositions (got ${parsed.protocol}): ${url}`,
);
}
if (isBlockedHost(parsed.hostname)) {
throw new Error(
`[URLDownloader] URL targets a private/reserved address and is not permitted: ${url}`,
);
}
}
function getFilenameFromUrl(url: string): string {
const hash = createHash("md5").update(url).digest("hex").slice(0, 12);
const urlObj = new URL(url);
const ext = extname(urlObj.pathname) || ".mp4";
return `download_${hash}${ext}`;
}
function hasCompleteFile(path: string): boolean {
if (!existsSync(path)) return false;
const entry = lstatSync(path);
if (entry.isFile() && entry.size > 0) return true;
// Old versions could leave an empty file behind. Never trust that stale
// cache entry—or a symlink/special entry planted at the final path.
rmSync(path, { recursive: entry.isDirectory(), force: true });
return false;
}
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const MAX_REDIRECTS = 5;
function assertAllowedDownloadUrl(url: string, redirect: boolean): void {
try {
assertPublicHttpsUrl(url);
} catch {
throw new UrlDownloadError(
"http_rejected",
false,
redirect ? "Download redirect target is not permitted" : "Download URL is not permitted",
);
}
}
async function cancelResponseBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// Redirect validation remains authoritative if teardown also fails.
}
}
function resolveRedirectUrl(response: Response, currentUrl: string, redirects: number): string {
if (redirects >= MAX_REDIRECTS) {
throw new UrlDownloadError("http_rejected", false, "Download exceeded redirect limit");
}
const location = response.headers.get("location");
if (!location) {
throw new UrlDownloadError(
"http_rejected",
false,
"Download redirect omitted a Location header",
);
}
try {
return new URL(location, currentUrl).toString();
} catch {
throw new UrlDownloadError("http_rejected", false, "Download redirect Location is invalid");
}
}
async function fetchWithValidatedRedirects(
initialUrl: string,
controller: AbortController,
): Promise<Response> {
let currentUrl = initialUrl;
for (let redirects = 0; ; redirects += 1) {
assertAllowedDownloadUrl(currentUrl, redirects > 0);
// lgtm[js/file-access-to-http] — every redirect hop is fetched manually
// only after the HTTPS/private-host guard above; automatic redirect
// following is disabled so an allowed host cannot bounce into IMDS.
const response = await fetch(currentUrl, {
signal: controller.signal,
redirect: "manual",
});
if (!REDIRECT_STATUSES.has(response.status)) return response;
await cancelResponseBody(response);
currentUrl = resolveRedirectUrl(response, currentUrl, redirects);
}
}
async function fetchToPartial(
url: string,
partialPath: string,
controller: AbortController,
): Promise<void> {
const response = await fetchWithValidatedRedirects(url, controller);
if (!response.ok) {
// Do not leave a streaming error response holding an Undici connection
// while the bounded retry starts.
try {
await response.body?.cancel();
} catch {
// The HTTP status remains the useful failure if teardown also fails.
}
throw classifyHttpFailure(response.status, response.statusText);
}
if (!response.body) {
throw new UrlDownloadError("empty_body", true, "Download response body is empty");
}
const fileStream = createWriteStream(partialPath, { flags: "wx" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const readableStream = Readable.fromWeb(response.body as any);
await pipeline(readableStream, fileStream);
if (statSync(partialPath).size === 0) {
throw new UrlDownloadError("empty_body", true, "Download response body contained zero bytes");
}
}
function syncAndPublishPartial(partialPath: string, localPath: string): void {
// Windows rejects fsync on a read-only handle (EPERM); the partial is ours
// and writable, so r+ preserves the same flush semantics cross-platform.
const fd = openSync(partialPath, "r+");
try {
fsyncSync(fd);
} finally {
closeSync(fd);
}
// Different cancellation scopes intentionally do not share a physical
// request. If another complete attempt won the final-path race, reuse it.
if (hasCompleteFile(localPath)) return;
try {
renameSync(partialPath, localPath);
} catch (error) {
if (!hasCompleteFile(localPath)) throw error;
}
}
async function runDownloadAttempt(
url: string,
localPath: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<string> {
// A private, unguessable directory prevents symlink planting and keeps the
// partial on the destination filesystem so the final rename stays atomic.
const attemptDir = mkdtempSync(join(dirname(localPath), ".hf-download-"));
const partialPath = join(attemptDir, "payload");
const controller = new AbortController();
let timedOut = false;
let callerAborted = signal?.aborted ?? false;
const onCallerAbort = (): void => {
callerAborted = true;
controller.abort();
};
signal?.addEventListener("abort", onCallerAbort, { once: true });
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
if (callerAborted) {
throw new UrlDownloadError("cancelled", false, "Download cancelled");
}
await fetchToPartial(url, partialPath, controller);
syncAndPublishPartial(partialPath, localPath);
return localPath;
} catch (error) {
if (callerAborted) {
throw new UrlDownloadError("cancelled", false, "Download cancelled");
}
if (timedOut) {
throw new UrlDownloadError("timeout", true, `Download timeout after ${timeoutMs / 1000}s`);
}
throw classifyDownloadFailure(error);
} finally {
clearTimeout(timeoutId);
signal?.removeEventListener("abort", onCallerAbort);
controller.abort();
rmSync(attemptDir, { recursive: true, force: true });
}
}
async function downloadWithRetry(
url: string,
localPath: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<string> {
const maxTransientRetries = 1;
for (let attempt = 0; ; attempt += 1) {
try {
return await runDownloadAttempt(url, localPath, timeoutMs, signal);
} catch (error) {
const classified = classifyDownloadFailure(error);
if (!classified.retryable || attempt >= maxTransientRetries) throw classified;
}
}
}
export async function downloadToTemp(
url: string,
destDir: string,
timeoutMs: number = 300000,
signal?: AbortSignal,
): Promise<string> {
// Reject non-HTTPS URLs and private/reserved address ranges before
// touching the cache or filesystem — customer-supplied compositions must
// not be able to trigger outbound fetches to internal infrastructure.
assertPublicHttpsUrl(url);
const cacheKey = `${url}\0${destDir}`;
// The physical request may be shared only by callers with the same
// cancellation scope and deadline. Otherwise the first caller's abort or
// timeout would incorrectly own every waiter.
const inFlightKey = `${cacheKey}\0${timeoutMs}\0${signalScopeKey(signal)}`;
const inFlight = inFlightDownloads.get(inFlightKey);
if (inFlight) {
return inFlight;
}
if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true });
}
const filename = getFilenameFromUrl(url);
const localPath = join(destDir, filename);
if (hasCompleteFile(localPath)) return localPath;
const downloadPromise = downloadWithRetry(url, localPath, timeoutMs, signal);
const trackedDownload = downloadPromise.finally(() => {
inFlightDownloads.delete(inFlightKey);
});
inFlightDownloads.set(inFlightKey, trackedDownload);
return trackedDownload;
}
export function isHttpUrl(path: string): boolean {
return path.startsWith("http://") || path.startsWith("https://");
}